diff --git a/apps/api/src/app/agents/agents-webhook.controller.ts b/apps/api/src/app/agents/agents-webhook.controller.ts deleted file mode 100644 index 4db1ce95958..00000000000 --- a/apps/api/src/app/agents/agents-webhook.controller.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { - Body, - Controller, - Get, - HttpCode, - HttpException, - HttpStatus, - Param, - Post, - Req, - Res, -} from '@nestjs/common'; -import { ApiExcludeController } from '@nestjs/swagger'; -import { PinoLogger } from '@novu/application-generic'; -import type { Signal } from '@novu/framework'; -import { UserSessionData } from '@novu/shared'; -import { Request, Response } from 'express'; -import { RequireAuthentication } from '../auth/framework/auth.decorator'; -import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; -import { UserSession } from '../shared/framework/user.decorator'; -import { AgentReplyPayloadDto } from './dtos/agent-reply-payload.dto'; -import { AgentInactiveException } from './exceptions/agent-inactive.exception'; -import type { AgentConfigResolveSource } from './services/agent-config-resolver.service'; -import { ChatSdkService } from './services/chat-sdk.service'; -import { ManagedAgentService } from './services/managed-agent.service'; -import { HandleAgentReplyCommand } from './usecases/handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from './usecases/handle-agent-reply/handle-agent-reply.usecase'; - -@Controller('/agents') -@ApiExcludeController() -export class AgentsWebhookController { - constructor( - private chatSdkService: ChatSdkService, - private handleAgentReplyUsecase: HandleAgentReply, - private managedAgentService: ManagedAgentService, - private readonly logger: PinoLogger - ) { - this.logger.setContext(this.constructor.name); - } - - @Post('/events') - async handleThalamusEvent(@Req() req: Request, @Res() res: Response) { - await this.managedAgentService.handleWebhook(req, res); - } - - @Post('/:agentId/reply') - @HttpCode(HttpStatus.OK) - @RequireAuthentication() - @ExternalApiAccessible() - async handleAgentReply( - @UserSession() user: UserSessionData, - @Param('agentId') agentId: string, - @Body() body: AgentReplyPayloadDto - ) { - return this.handleAgentReplyUsecase.execute( - HandleAgentReplyCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - conversationId: body.conversationId, - agentIdentifier: agentId, - integrationIdentifier: body.integrationIdentifier, - reply: body.reply, - edit: body.edit, - resolve: body.resolve, - signals: body.signals as Signal[], - addReactions: body.addReactions, - }) - ); - } - - @Get('/:agentId/webhook/:integrationIdentifier') - async handleWebhookVerification( - @Param('agentId') agentId: string, - @Param('integrationIdentifier') integrationIdentifier: string, - @Req() req: Request, - @Res() res: Response - ) { - return this.routeWebhook(agentId, integrationIdentifier, req, res, 'webhook_verification'); - } - - @Post('/:agentId/webhook/:integrationIdentifier') - @HttpCode(HttpStatus.OK) - async handleInboundWebhook( - @Param('agentId') agentId: string, - @Param('integrationIdentifier') integrationIdentifier: string, - @Req() req: Request, - @Res() res: Response - ) { - return this.routeWebhook(agentId, integrationIdentifier, req, res, 'webhook_message'); - } - - private async routeWebhook( - agentId: string, - integrationIdentifier: string, - req: Request, - res: Response, - source: AgentConfigResolveSource - ) { - try { - await this.chatSdkService.handleWebhook(agentId, integrationIdentifier, req, res, { source }); - } catch (err) { - if (err instanceof AgentInactiveException) { - // Return 200 to avoid retries by the delivery provider - res.status(HttpStatus.OK).json({}); - - return; - } - - if (err instanceof HttpException) { - res.status(err.getStatus()).json(err.getResponse()); - } else { - throw err; - } - } - } -} diff --git a/apps/api/src/app/agents/agents.controller.ts b/apps/api/src/app/agents/agents.controller.ts deleted file mode 100644 index f0ed2aba70e..00000000000 --- a/apps/api/src/app/agents/agents.controller.ts +++ /dev/null @@ -1,1098 +0,0 @@ -import { - Body, - ClassSerializerInterceptor, - Controller, - Delete, - Get, - HttpCode, - HttpStatus, - Param, - Patch, - Post, - Put, - Query, - Req, - UseFilters, - UseInterceptors, -} from '@nestjs/common'; -import { ApiExcludeController, ApiExcludeEndpoint, ApiOperation } from '@nestjs/swagger'; -import { ProductFeature, RequirePermissions } from '@novu/application-generic'; -import { - ApiRateLimitCategoryEnum, - DirectionEnum, - PermissionsEnum, - ProductFeatureKeyEnum, - UserSessionData, -} from '@novu/shared'; -import type { Request } from 'express'; -import { RequireAuthentication } from '../auth/framework/auth.decorator'; -import { ExternalApiAccessible } from '../auth/framework/external-api.decorator'; -import { ThrottlerCategory } from '../rate-limiting/guards'; -import { - ApiCommonResponses, - ApiConflictResponse, - ApiNoContentResponse, - ApiNotFoundResponse, - ApiResponse, -} from '../shared/framework/response.decorator'; -import { UserSession } from '../shared/framework/user.decorator'; -import { - AddAgentIntegrationRequestDto, - AgentIntegrationResponseDto, - AgentMcpServerEnablementResponseDto, - AgentResponseDto, - AgentRuntimeConfigResponseDto, - CreateAgentRequestDto, - EnableAgentMcpServerRequestDto, - GenerateManagedAgentRequestDto, - GenerateManagedAgentResponseDto, - GenerateMcpOAuthUrlRequestDto, - GenerateMcpOAuthUrlResponseDto, - ListAgentIntegrationsQueryDto, - ListAgentIntegrationsResponseDto, - ListAgentMcpServersResponseDto, - ListAgentsQueryDto, - ListAgentsResponseDto, - McpConnectionResponseDto, - MigrateAgentRuntimeRequestDto, - PatchAgentRuntimeConfigRequestDto, - SetAgentMcpServersRequestDto, - SetAgentMcpServersResponseDto, - UpdateAgentBridgeRequestDto, - UpdateAgentInboxSharedRequestDto, - UpdateAgentIntegrationRequestDto, - UpdateAgentRequestDto, - UploadCustomSkillRequestDto, - UploadCustomSkillResponseDto, - VerifyManagedCredentialsRequestDto, - VerifyManagedCredentialsResponseDto, -} from './dtos'; -import { ConfigureTelegramWebhookResponseDto } from './dtos/configure-telegram-webhook-response.dto'; -import { ConfigureWhatsAppWebhookResponseDto } from './dtos/configure-whatsapp-webhook-response.dto'; -import { IssueTelegramMobileLinkRequestDto } from './dtos/issue-telegram-mobile-link-request.dto'; -import { IssueTelegramMobileLinkResponseDto } from './dtos/issue-telegram-mobile-link-response.dto'; -import { IssueTelegramSubscriberLinkRequestDto } from './dtos/issue-telegram-subscriber-link-request.dto'; -import { IssueTelegramSubscriberLinkResponseDto } from './dtos/issue-telegram-subscriber-link-response.dto'; -import { SendAgentTestEmailRequestDto } from './dtos/send-agent-test-email-request.dto'; -import { SendAgentWelcomeMessageRequestDto } from './dtos/send-agent-welcome-message-request.dto'; -import { - SendWhatsAppTestTemplateRequestDto, - SendWhatsAppTestTemplateResponseDto, -} from './dtos/send-whatsapp-test-template.dto'; -import { AgentRuntimeExceptionFilter } from './filters/agent-runtime-exception.filter'; -import { AddAgentIntegrationCommand } from './usecases/add-agent-integration/add-agent-integration.command'; -import { AddAgentIntegration } from './usecases/add-agent-integration/add-agent-integration.usecase'; -import { ConfigureTelegramAgentWebhookCommand } from './usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command'; -import { ConfigureTelegramAgentWebhook } from './usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; -import { ConfigureWhatsAppWebhookCommand } from './usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.command'; -import { ConfigureWhatsAppWebhook } from './usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase'; -import { CreateAgentCommand } from './usecases/create-agent/create-agent.command'; -import { CreateAgent } from './usecases/create-agent/create-agent.usecase'; -import { DeleteAgentCommand } from './usecases/delete-agent/delete-agent.command'; -import { DeleteAgent } from './usecases/delete-agent/delete-agent.usecase'; -import { DisableAgentMcpServerCommand } from './usecases/disable-agent-mcp-server/disable-agent-mcp-server.command'; -import { DisableAgentMcpServer } from './usecases/disable-agent-mcp-server/disable-agent-mcp-server.usecase'; -import { EnableAgentMcpServerCommand } from './usecases/enable-agent-mcp-server/enable-agent-mcp-server.command'; -import { EnableAgentMcpServer } from './usecases/enable-agent-mcp-server/enable-agent-mcp-server.usecase'; -import { GenerateManagedAgentCommand } from './usecases/generate-managed-agent/generate-managed-agent.command'; -import { GenerateManagedAgent } from './usecases/generate-managed-agent/generate-managed-agent.usecase'; -import { GenerateMcpOAuthUrlCommand } from './usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.command'; -import { GenerateMcpOAuthUrl } from './usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; -import { GetAgentCommand } from './usecases/get-agent/get-agent.command'; -import { GetAgent } from './usecases/get-agent/get-agent.usecase'; -import { GetAgentDemoQuotaCommand } from './usecases/get-agent-demo-quota/get-agent-demo-quota.command'; -import { GetAgentDemoQuota } from './usecases/get-agent-demo-quota/get-agent-demo-quota.usecase'; -import { GetAgentRuntimeConfigCommand } from './usecases/get-agent-runtime-config/get-agent-runtime-config.command'; -import { GetAgentRuntimeConfig } from './usecases/get-agent-runtime-config/get-agent-runtime-config.usecase'; -import { GetMcpConnectionStatusCommand } from './usecases/get-mcp-connection-status/get-mcp-connection-status.command'; -import { GetMcpConnectionStatus } from './usecases/get-mcp-connection-status/get-mcp-connection-status.usecase'; -import { IssueTelegramMobileLinkCommand } from './usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.command'; -import { IssueTelegramMobileLink } from './usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase'; -import { IssueTelegramSubscriberLinkCommand } from './usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command'; -import { IssueTelegramSubscriberLink } from './usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; -import { type AgentEmojiEntry, ListAgentEmoji } from './usecases/list-agent-emoji/list-agent-emoji.usecase'; -import { ListAgentIntegrationsCommand } from './usecases/list-agent-integrations/list-agent-integrations.command'; -import { ListAgentIntegrations } from './usecases/list-agent-integrations/list-agent-integrations.usecase'; -import { ListAgentMcpServersCommand } from './usecases/list-agent-mcp-servers/list-agent-mcp-servers.command'; -import { ListAgentMcpServers } from './usecases/list-agent-mcp-servers/list-agent-mcp-servers.usecase'; -import { ListAgentsCommand } from './usecases/list-agents/list-agents.command'; -import { ListAgents } from './usecases/list-agents/list-agents.usecase'; -import { MigrateAgentRuntimeCommand } from './usecases/migrate-agent-runtime/migrate-agent-runtime.command'; -import { MigrateAgentRuntime } from './usecases/migrate-agent-runtime/migrate-agent-runtime.usecase'; -import { RemoveAgentIntegrationCommand } from './usecases/remove-agent-integration/remove-agent-integration.command'; -import { RemoveAgentIntegration } from './usecases/remove-agent-integration/remove-agent-integration.usecase'; -import { SendAgentTestEmailCommand } from './usecases/send-agent-test-email/send-agent-test-email.command'; -import { SendAgentTestEmail } from './usecases/send-agent-test-email/send-agent-test-email.usecase'; -import { SendAgentWelcomeMessageCommand } from './usecases/send-agent-welcome-message/send-agent-welcome-message.command'; -import { SendAgentWelcomeMessage } from './usecases/send-agent-welcome-message/send-agent-welcome-message.usecase'; -import { SendWhatsAppTestTemplateCommand } from './usecases/send-whatsapp-test-template/send-whatsapp-test-template.command'; -import { SendWhatsAppTestTemplate } from './usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase'; -import { SetAgentMcpServersCommand } from './usecases/set-agent-mcp-servers/set-agent-mcp-servers.command'; -import { SetAgentMcpServers } from './usecases/set-agent-mcp-servers/set-agent-mcp-servers.usecase'; -import { UpdateAgentCommand } from './usecases/update-agent/update-agent.command'; -import { UpdateAgent } from './usecases/update-agent/update-agent.usecase'; -import { UpdateAgentInboxSharedCommand } from './usecases/update-agent-inbox-shared/update-agent-inbox-shared.command'; -import { UpdateAgentInboxShared } from './usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase'; -import { UpdateAgentIntegrationCommand } from './usecases/update-agent-integration/update-agent-integration.command'; -import { UpdateAgentIntegration } from './usecases/update-agent-integration/update-agent-integration.usecase'; -import { UpdateAgentRuntimeConfigCommand } from './usecases/update-agent-runtime-config/update-agent-runtime-config.command'; -import { UpdateAgentRuntimeConfig } from './usecases/update-agent-runtime-config/update-agent-runtime-config.usecase'; -import { UploadCustomSkillCommand } from './usecases/upload-custom-skill/upload-custom-skill.command'; -import { UploadCustomSkill } from './usecases/upload-custom-skill/upload-custom-skill.usecase'; -import { VerifyManagedCredentialsCommand } from './usecases/verify-managed-credentials/verify-managed-credentials.command'; -import { VerifyManagedCredentials } from './usecases/verify-managed-credentials/verify-managed-credentials.usecase'; - -@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) -@ApiCommonResponses() -@Controller('/agents') -@UseInterceptors(ClassSerializerInterceptor) -@ApiExcludeController() -@RequireAuthentication() -export class AgentsController { - constructor( - private readonly createAgentUsecase: CreateAgent, - private readonly listAgentsUsecase: ListAgents, - private readonly getAgentUsecase: GetAgent, - private readonly updateAgentUsecase: UpdateAgent, - private readonly deleteAgentUsecase: DeleteAgent, - private readonly addAgentIntegrationUsecase: AddAgentIntegration, - private readonly listAgentIntegrationsUsecase: ListAgentIntegrations, - private readonly updateAgentIntegrationUsecase: UpdateAgentIntegration, - private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration, - private readonly listAgentEmojiUsecase: ListAgentEmoji, - private readonly sendAgentTestEmailUsecase: SendAgentTestEmail, - private readonly sendAgentWelcomeMessageUsecase: SendAgentWelcomeMessage, - private readonly getAgentRuntimeConfigUsecase: GetAgentRuntimeConfig, - private readonly updateAgentRuntimeConfigUsecase: UpdateAgentRuntimeConfig, - private readonly uploadCustomSkillUsecase: UploadCustomSkill, - private readonly configureWhatsAppWebhookUsecase: ConfigureWhatsAppWebhook, - private readonly sendWhatsAppTestTemplateUsecase: SendWhatsAppTestTemplate, - private readonly enableAgentMcpServerUsecase: EnableAgentMcpServer, - private readonly disableAgentMcpServerUsecase: DisableAgentMcpServer, - private readonly setAgentMcpServersUsecase: SetAgentMcpServers, - private readonly listAgentMcpServersUsecase: ListAgentMcpServers, - private readonly generateMcpOAuthUrlUsecase: GenerateMcpOAuthUrl, - private readonly getMcpConnectionStatusUsecase: GetMcpConnectionStatus, - private readonly configureTelegramAgentWebhookUsecase: ConfigureTelegramAgentWebhook, - private readonly issueTelegramMobileLinkUsecase: IssueTelegramMobileLink, - private readonly issueTelegramSubscriberLinkUsecase: IssueTelegramSubscriberLink, - private readonly updateAgentInboxSharedUsecase: UpdateAgentInboxShared, - private readonly verifyManagedCredentialsUsecase: VerifyManagedCredentials, - private readonly generateManagedAgentUsecase: GenerateManagedAgent, - private readonly getAgentDemoQuotaUsecase: GetAgentDemoQuota, - private readonly migrateAgentRuntimeUsecase: MigrateAgentRuntime - ) {} - - @Get('/emoji') - @ApiOperation({ - summary: 'List available emoji', - description: - 'Returns the set of well-known cross-platform emoji names supported for agent reactions. ' + - 'Each entry includes the normalized name and a unicode representation for display.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - listAgentEmoji(): Promise { - return this.listAgentEmojiUsecase.execute(); - } - - @Post('/verify-credentials') - @ExternalApiAccessible() - @ApiResponse(VerifyManagedCredentialsResponseDto) - @ApiOperation({ - summary: 'Verify managed-runtime credentials', - description: - 'Performs a stateless, read-only validation of the supplied API key against the selected managed-runtime provider. ' + - 'Used by the dashboard to give immediate feedback when configuring credentials before the integration is created.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - verifyManagedCredentials( - @UserSession() user: UserSessionData, - @Body() body: VerifyManagedCredentialsRequestDto - ): Promise { - return this.verifyManagedCredentialsUsecase.execute( - VerifyManagedCredentialsCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - providerId: body.providerId, - apiKey: body.apiKey, - externalWorkspaceId: body.externalWorkspaceId, - region: body.region, - }) - ); - } - - @Post('/generate') - @ExternalApiAccessible() - @ApiResponse(GenerateManagedAgentResponseDto) - @ApiOperation({ - summary: 'Generate an agent configuration from a free-form prompt', - description: - 'Translates a user-supplied description into an agent configuration (name, identifier, systemPrompt, tools, MCP servers, skills).', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - async generateManagedAgent( - @UserSession() user: UserSessionData, - @Body() body: GenerateManagedAgentRequestDto, - @Req() request: Request - ): Promise { - const abortController = new AbortController(); - const handleSocketClose = (): void => { - if (request.destroyed) { - abortController.abort(); - } - }; - request.socket.on('close', handleSocketClose); - - const command = GenerateManagedAgentCommand.create({ - user, - prompt: body.prompt, - runtime: body.runtime, - }); - // Attach signal outside `create(...)` — running an `AbortSignal` through - // `class-transformer`'s `plainToInstance` triggers `new AbortSignal()`, which is - // disallowed by the runtime (`ERR_ILLEGAL_CONSTRUCTOR`). - command.signal = abortController.signal; - - try { - return await this.generateManagedAgentUsecase.execute(command); - } finally { - request.socket.off('close', handleSocketClose); - } - } - - @Post('/') - @ExternalApiAccessible() - @ApiResponse(AgentResponseDto, 201) - @ApiOperation({ - summary: 'Create agent', - description: 'Creates an agent scoped to the current environment. The identifier must be unique per environment.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - createAgent(@UserSession() user: UserSessionData, @Body() body: CreateAgentRequestDto): Promise { - return this.createAgentUsecase.execute( - CreateAgentCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - name: body.name, - identifier: body.identifier, - description: body.description, - active: body.active, - runtime: body.runtime, - managedRuntime: body.managedRuntime, - }) - ); - } - - @Get('/') - @ExternalApiAccessible() - @ApiResponse(ListAgentsResponseDto) - @ApiOperation({ - summary: 'List agents', - description: - 'Returns a cursor-paginated list of agents for the current environment. Use **after**, **before**, **limit**, **orderBy**, and **orderDirection** query parameters.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - listAgents(@UserSession() user: UserSessionData, @Query() query: ListAgentsQueryDto): Promise { - return this.listAgentsUsecase.execute( - ListAgentsCommand.create({ - user, - environmentId: user.environmentId, - organizationId: user.organizationId, - limit: Number(query.limit || '10'), - after: query.after, - before: query.before, - orderDirection: query.orderDirection || DirectionEnum.DESC, - orderBy: query.orderBy || '_id', - includeCursor: query.includeCursor, - identifier: query.identifier, - }) - ); - } - - @Post('/:identifier/integrations') - @ExternalApiAccessible() - @ApiResponse(AgentIntegrationResponseDto, 201) - @ApiOperation({ - summary: 'Link integration to agent', - description: - 'Creates a link between an agent (by identifier) and an integration (by integration **identifier**, not the internal _id).', - }) - @ApiNotFoundResponse({ - description: 'The agent or integration was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - addAgentIntegration( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: AddAgentIntegrationRequestDto - ): Promise { - return this.addAgentIntegrationUsecase.execute( - AddAgentIntegrationCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationIdentifier: body.integrationIdentifier, - providerId: body.providerId, - }) - ); - } - - @Get('/:identifier/integrations') - @ExternalApiAccessible() - @ApiResponse(ListAgentIntegrationsResponseDto) - @ApiOperation({ - summary: 'List agent integrations', - description: - 'Lists integration links for an agent identified by its external identifier. Supports cursor pagination via **after**, **before**, **limit**, **orderBy**, and **orderDirection**.', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - listAgentIntegrations( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Query() query: ListAgentIntegrationsQueryDto - ): Promise { - return this.listAgentIntegrationsUsecase.execute( - ListAgentIntegrationsCommand.create({ - user, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - limit: Number(query.limit || '10'), - after: query.after, - before: query.before, - orderDirection: query.orderDirection || DirectionEnum.DESC, - orderBy: query.orderBy || '_id', - includeCursor: query.includeCursor, - integrationIdentifier: query.integrationIdentifier, - }) - ); - } - - @Patch('/:identifier/integrations/:agentIntegrationId') - @ApiResponse(AgentIntegrationResponseDto) - @ApiOperation({ - summary: 'Update agent-integration link', - description: 'Updates which integration a link points to (by integration **identifier**, not the internal _id).', - }) - @ApiNotFoundResponse({ - description: 'The agent, integration, or link was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - updateAgentIntegration( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('agentIntegrationId') agentIntegrationId: string, - @Body() body: UpdateAgentIntegrationRequestDto - ): Promise { - return this.updateAgentIntegrationUsecase.execute( - UpdateAgentIntegrationCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - agentIntegrationId, - integrationIdentifier: body.integrationIdentifier, - }) - ); - } - - @Delete('/:identifier/integrations/:agentIntegrationId') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: 'Remove agent-integration link', - description: 'Deletes a specific agent-integration link by its document id.', - }) - @ApiNoContentResponse({ - description: 'The link was removed.', - }) - @ApiNotFoundResponse({ - description: 'The agent or agent-integration link was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - removeAgentIntegration( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('agentIntegrationId') agentIntegrationId: string - ): Promise { - return this.removeAgentIntegrationUsecase.execute( - RemoveAgentIntegrationCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - agentIntegrationId, - }) - ); - } - - @Post('/:identifier/integrations/:integrationIdentifier/whatsapp/auto-configure') - @ApiExcludeEndpoint() - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Auto-configure the WhatsApp webhook for an agent integration', - description: - 'Calls Meta to register Novu as the webhook callback for the connected WhatsApp Business Account, subscribing to message events with the auto-generated verify token. Falls back to manual configuration when the access token lacks the management scope.', - }) - @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - configureAgentWhatsAppWebhook( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('integrationIdentifier') integrationIdentifier: string - ): Promise { - return this.configureWhatsAppWebhookUsecase.execute( - ConfigureWhatsAppWebhookCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationIdentifier, - }) - ); - } - - @Post('/:identifier/integrations/:integrationIdentifier/whatsapp/test-template') - @ApiExcludeEndpoint() - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Send a hello_world WhatsApp template from the agent integration', - description: - 'Sends the standard `hello_world` template via the configured WhatsApp Business phone number to a recipient supplied by the user, used at the end of the onboarding flow to verify outbound delivery without asking the user to send an inbound message themselves.', - }) - @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - sendAgentWhatsAppTestTemplate( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('integrationIdentifier') integrationIdentifier: string, - @Body() body: SendWhatsAppTestTemplateRequestDto - ): Promise { - return this.sendWhatsAppTestTemplateUsecase.execute( - SendWhatsAppTestTemplateCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationIdentifier, - subscriberId: body.subscriberId, - }) - ); - } - - @Post('/:identifier/test-email') - @HttpCode(HttpStatus.OK) - @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) - @ApiOperation({ - summary: 'Send a test email to the agent inbound address', - description: - 'Sends a test email to the configured inbound address using the agent outbound provider (or the Novu demo integration as fallback). Used to verify the inbound email pipeline.', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - sendAgentTestEmail( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: SendAgentTestEmailRequestDto - ): Promise<{ success: boolean }> { - return this.sendAgentTestEmailUsecase.execute( - SendAgentTestEmailCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - targetAddress: body.targetAddress, - }) - ); - } - - @Patch('/:identifier/inbox/shared') - @ApiResponse(AgentIntegrationResponseDto) - @ApiOperation({ - summary: 'Enable or disable the Novu shared inbox for an agent', - description: - 'Disabling drops inbound mail addressed to this agent on the shared `agentconnect.sh` domain — custom-domain ' + - 'routes continue to deliver. Refused when no custom-domain inbox is configured (would leave the agent with ' + - 'zero inbound paths).', - }) - @ApiNotFoundResponse({ description: 'The agent or its Novu Email integration was not found.' }) - @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - updateAgentInboxShared( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: UpdateAgentInboxSharedRequestDto - ): Promise { - return this.updateAgentInboxSharedUsecase.execute( - UpdateAgentInboxSharedCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - disabled: body.disabled, - }) - ); - } - - @Post('/:identifier/welcome-message') - @ExternalApiAccessible() - @HttpCode(HttpStatus.OK) - @ApiOperation({ - summary: 'Send onboarding welcome message', - description: - 'Sends a proactive DM to the agent installer after Slack OAuth, or posts a bridge-connected ' + - 'follow-up message into an existing conversation thread when conversationId is supplied.', - }) - @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - sendAgentWelcomeMessage( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: SendAgentWelcomeMessageRequestDto - ): Promise<{ sent: boolean; conversationId?: string }> { - return this.sendAgentWelcomeMessageUsecase.execute( - SendAgentWelcomeMessageCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationIdentifier: body.integrationIdentifier, - conversationId: body.conversationId, - }) - ); - } - - @Post('/:identifier/integrations/:integrationId/telegram/configure') - @ExternalApiAccessible() - @HttpCode(HttpStatus.OK) - @ApiResponse(ConfigureTelegramWebhookResponseDto, 200) - @ApiOperation({ - summary: 'Configure Telegram bot webhook', - description: `Registers the Novu agent webhook URL with Telegram for the specified integration, - generates a cryptographic secret token for webhook verification, - and persists it on the integration. Re-running rotates the secret.`, - }) - @ApiNotFoundResponse({ - description: 'The agent, integration, or agent-integration link was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - updateTelegramWebhook( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('integrationId') integrationId: string - ): Promise { - return this.configureTelegramAgentWebhookUsecase.execute( - ConfigureTelegramAgentWebhookCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationId, - }) - ); - } - - @Post('/:identifier/integrations/:integrationId/telegram/mobile-link') - @ExternalApiAccessible() - @HttpCode(HttpStatus.OK) - @ApiResponse(IssueTelegramMobileLinkResponseDto, 200) - @ApiOperation({ - summary: 'Issue a short-lived Telegram mobile setup link', - description: - 'Issues a signed, single-use link (TTL = 5 minutes) that can be opened on a mobile device to finish ' + - 'configuring a Telegram bot without re-authenticating. Telegram-only.', - }) - @ApiNotFoundResponse({ - description: 'The agent, integration, or agent-integration link was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - createTelegramMobileLink( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('integrationId') integrationId: string, - @Body() body?: IssueTelegramMobileLinkRequestDto - ): Promise { - return this.issueTelegramMobileLinkUsecase.execute( - IssueTelegramMobileLinkCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationId, - subscriberId: body?.subscriberId, - }) - ); - } - - @Post('/:identifier/integrations/:integrationId/telegram/subscriber-link') - @ExternalApiAccessible() - @HttpCode(HttpStatus.OK) - @ApiResponse(IssueTelegramSubscriberLinkResponseDto, 200) - @ApiOperation({ - summary: 'Issue a Telegram subscriber-link deep link', - description: - 'Issues a short-lived opaque start code and returns a Telegram `t.me/?start=` deep link. When ' + - 'opened, Telegram sends `/start ` to the bot; the agent webhook consumes the code server-side and ' + - 'creates a `telegram_chat` channel endpoint so notifications can reach that subscriber via Telegram.', - }) - @ApiNotFoundResponse({ - description: 'The agent, integration, agent-integration link, or subscriber was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - createTelegramSubscriberLink( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('integrationId') integrationId: string, - @Body() body: IssueTelegramSubscriberLinkRequestDto - ): Promise { - return this.issueTelegramSubscriberLinkUsecase.execute( - IssueTelegramSubscriberLinkCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - integrationId, - subscriberId: body.subscriberId, - }) - ); - } - - @Put('/:identifier/bridge') - @ApiResponse(AgentResponseDto) - @ApiOperation({ - summary: 'Update agent bridge configuration', - description: - 'Updates the bridge URL configuration for an agent. Used by the CLI to register dev tunnel URLs. Refuses to activate dev bridges on production environments.', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @ExternalApiAccessible() - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - updateAgentBridge( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: UpdateAgentBridgeRequestDto - ): Promise { - return this.updateAgentUsecase.execute( - UpdateAgentCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - bridgeUrl: body.bridgeUrl, - devBridgeUrl: body.devBridgeUrl, - devBridgeActive: body.devBridgeActive, - }) - ); - } - - @Get('/:identifier/demo-quota') - @ApiOperation({ - summary: 'Get Novu managed Claude demo quota', - description: - 'Returns monthly conversation and token usage limits for agents running on the Novu-managed Claude demo integration.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - getAgentDemoQuota(@UserSession() user: UserSessionData, @Param('identifier') identifier: string) { - return this.getAgentDemoQuotaUsecase.execute( - GetAgentDemoQuotaCommand.create({ - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - }) - ); - } - - @Post('/:identifier/migrate-runtime') - @ApiOperation({ - summary: 'Migrate managed agent off Novu demo Claude credentials', - description: - 'Re-points a managed agent from the Novu demo Claude integration to a user-owned Anthropic integration, copying runtime config and clearing demo sessions.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - migrateAgentRuntime( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: MigrateAgentRuntimeRequestDto - ) { - return this.migrateAgentRuntimeUsecase.execute( - MigrateAgentRuntimeCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - integrationId: body.integrationId, - }) - ); - } - - @Get('/:identifier') - @ApiResponse(AgentResponseDto) - @ApiOperation({ - summary: 'Get agent', - description: 'Retrieves an agent by its external identifier (not the internal MongoDB id).', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - getAgent(@UserSession() user: UserSessionData, @Param('identifier') identifier: string): Promise { - return this.getAgentUsecase.execute( - GetAgentCommand.create({ - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - }) - ); - } - - @Patch('/:identifier') - @ApiResponse(AgentResponseDto) - @ApiOperation({ - summary: 'Update agent', - description: 'Updates an agent by its external identifier.', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - updateAgent( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: UpdateAgentRequestDto - ): Promise { - return this.updateAgentUsecase.execute( - UpdateAgentCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - name: body.name, - description: body.description, - active: body.active, - behavior: body.behavior, - bridgeUrl: body.bridgeUrl, - devBridgeUrl: body.devBridgeUrl, - devBridgeActive: body.devBridgeActive, - }) - ); - } - - @Delete('/:identifier') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: 'Delete agent', - description: - 'Deletes an agent by identifier and removes all agent-integration links. ' + - 'For managed-runtime agents, pass `deleteFromProvider=true` to also archive the agent on the provider side (e.g. Anthropic). ' + - 'By default only the Novu record is deleted and the provider agent is left intact.', - }) - @ApiNoContentResponse({ - description: 'The agent was deleted.', - }) - @ApiNotFoundResponse({ - description: 'The agent was not found.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - deleteAgent( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Query('deleteFromProvider') deleteFromProvider?: string - ): Promise { - return this.deleteAgentUsecase.execute( - DeleteAgentCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - deleteFromProvider: deleteFromProvider === 'true', - }) - ); - } - - @Get('/:identifier/runtime/config') - @ApiResponse(AgentRuntimeConfigResponseDto, 200) - @ApiOperation({ - summary: 'Get agent runtime config', - description: - 'Fetches the live runtime configuration for a managed agent from the provider ' + - '(model, system prompt, MCP servers, tools). Returns 422 for self-hosted agents.', - }) - @ApiNotFoundResponse({ description: 'Agent or its runtime integration was not found.' }) - @ApiConflictResponse({ - description: - 'AGENT_RUNTIME_DRIFT — the agent record exists in Novu but the provider reports it as deleted or unreachable. ' + - 'Re-provision or delete the agent.', - }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - @UseFilters(AgentRuntimeExceptionFilter) - getAgentRuntimeConfig( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string - ): Promise { - return this.getAgentRuntimeConfigUsecase.execute( - GetAgentRuntimeConfigCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - }) - ); - } - - @Patch('/:identifier/runtime/config') - @ApiResponse(AgentRuntimeConfigResponseDto, 200) - @ApiOperation({ - summary: 'Update agent runtime config', - description: - 'Applies a partial update to the managed agent runtime config on the provider. ' + - 'Accepts any combination of model, systemPrompt, tools, and skills. ' + - 'MCP enablement is managed via the dedicated `POST /agents/:identifier/mcp-servers` and ' + - '`DELETE /agents/:identifier/mcp-servers/:mcpId` endpoints. ' + - 'Server-side diffing issues the minimal set of provider API calls. ' + - 'An empty body is accepted and returns the current config unchanged.', - }) - @ApiNotFoundResponse({ description: 'Agent or its runtime integration was not found.' }) - @ApiConflictResponse({ - description: - 'AGENT_RUNTIME_DRIFT — the agent record exists in Novu but the provider reports it as deleted or unreachable. ' + - 'Re-provision or delete the agent.', - }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - updateAgentRuntimeConfig( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: PatchAgentRuntimeConfigRequestDto - ): Promise { - return this.updateAgentRuntimeConfigUsecase.execute( - UpdateAgentRuntimeConfigCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - identifier, - model: body.model, - systemPrompt: body.systemPrompt, - tools: body.tools, - skills: body.skills, - }) - ); - } - - @Get('/:identifier/mcp-servers') - @ApiResponse(ListAgentMcpServersResponseDto) - @ApiOperation({ - summary: 'List MCP servers enabled on agent', - description: - 'Returns the per-agent enablement records sourced from Mongo. Mongo is the source of truth for ' + - 'the agent\u2019s MCP list; the provider\u2019s `agent.mcp_servers` collection is synced from these rows.', - }) - @ApiNotFoundResponse({ description: 'The agent was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - listAgentMcpServers( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string - ): Promise { - return this.listAgentMcpServersUsecase.execute( - ListAgentMcpServersCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - }) - ); - } - - @Post('/:identifier/mcp-servers') - @ApiResponse(AgentMcpServerEnablementResponseDto, 201) - @ApiOperation({ - summary: 'Enable an MCP server on agent', - description: - 'Writes the per-agent enablement record and synchronously projects the new enabled set onto the runtime provider.', - }) - @ApiNotFoundResponse({ description: 'The agent or runtime integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - enableAgentMcpServer( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: EnableAgentMcpServerRequestDto - ): Promise { - return this.enableAgentMcpServerUsecase.execute( - EnableAgentMcpServerCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - mcpId: body.mcpId, - defaultScope: body.defaultScope, - }) - ); - } - - @Put('/:identifier/mcp-servers') - @HttpCode(HttpStatus.OK) - @ApiResponse(SetAgentMcpServersResponseDto, 200) - @ApiOperation({ - summary: 'Replace the agent\u2019s enabled MCP server set', - description: - 'Idempotent bulk update: ids in the request not currently enabled are enabled, currently-enabled ids ' + - 'missing from the request are disabled, the rest are untouched. Catalog validation fails the whole ' + - 'request up-front (no partial writes for malformed input). Per-row business / provider errors are ' + - 'collected into `failed[]` so a single bad row never strands the other edits; the dashboard surfaces ' + - 'these failures and refetches the list to render the truth.', - }) - @ApiNotFoundResponse({ description: 'The agent or runtime integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - setAgentMcpServers( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Body() body: SetAgentMcpServersRequestDto - ): Promise { - return this.setAgentMcpServersUsecase.execute( - SetAgentMcpServersCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - mcpIds: body.mcpIds, - }) - ); - } - - @Delete('/:identifier/mcp-servers/:mcpId') - @HttpCode(HttpStatus.NO_CONTENT) - @ApiOperation({ - summary: 'Disable an MCP server on agent', - description: - 'Cascade-deletes any `mcp_connection` rows scoped to this enablement, removes the per-agent record, and resyncs the provider projection.', - }) - @ApiNoContentResponse({ description: 'The MCP was disabled.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - disableAgentMcpServer( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('mcpId') mcpId: string - ): Promise { - return this.disableAgentMcpServerUsecase.execute( - DisableAgentMcpServerCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - mcpId, - }) - ); - } - - @Post('/:identifier/mcp-servers/:mcpId/oauth/url') - @HttpCode(HttpStatus.OK) - @ApiResponse(GenerateMcpOAuthUrlResponseDto, 200) - @ApiOperation({ - summary: 'Generate MCP OAuth authorize URL', - description: - 'Returns the provider authorize URL the subscriber should be redirected to for a `subscriber`-scoped connection. ' + - 'Reuses the signed-state OAuth pattern already used by chat integrations.', - }) - @ExternalApiAccessible() - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - generateMcpOAuthUrl( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('mcpId') mcpId: string, - @Body() body: GenerateMcpOAuthUrlRequestDto - ): Promise { - return this.generateMcpOAuthUrlUsecase.execute( - GenerateMcpOAuthUrlCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - mcpId, - subscriberId: body.subscriberId, - conversationId: body.conversationId, - }) - ); - } - - @Get('/:identifier/mcp-servers/:mcpId/connection') - @ApiResponse(McpConnectionResponseDto) - @ApiOperation({ - summary: 'Get MCP connection status for a subscriber', - description: - 'Returns the per-subscriber connection state for the (agent, mcp) pair, or null when no connection has been initiated yet. ' + - 'Used by the dashboard to render Authorize / Connected / Re-authorize CTAs without leaking encrypted tokens.', - }) - @ApiNotFoundResponse({ description: 'Agent or MCP enablement not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_READ) - getMcpConnectionStatus( - @UserSession() user: UserSessionData, - @Param('identifier') identifier: string, - @Param('mcpId') mcpId: string, - @Query('subscriberId') subscriberId: string - ): Promise { - return this.getMcpConnectionStatusUsecase.execute( - GetMcpConnectionStatusCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - agentIdentifier: identifier, - mcpId, - subscriberId, - }) - ); - } - - @Post('/skills') - @HttpCode(HttpStatus.CREATED) - @ApiResponse(UploadCustomSkillResponseDto, 201) - @ApiOperation({ - summary: 'Upload one or more custom skills from a source', - description: - 'Downloads the supplied source, uploads each resulting bundle to the integration provider ' + - 'as a custom skill, and returns the provider-assigned skill IDs as a uniform `skills[]` array. ' + - 'Three source variants are supported:\n\n' + - '- `type: "github-url"` — full `https://github.com/...` URL. Always uploads exactly one skill; ' + - 'use this form to pin a ref or to disambiguate when multiple repo directories share a basename. ' + - 'Accepts `/`, `/tree/{ref}`, or `/tree/{ref}/{path}` shapes.\n' + - '- `type: "github-repo"` — `owner/repo` slug fetched from the default branch (HEAD). ' + - 'Pass a required, non-empty `skills` array of directory basenames to upload. Each name must ' + - 'match exactly one directory containing a `SKILL.md`; ambiguous names are rejected with a 400.\n' + - '- `type: "inline"` — raw `SKILL.md` text pasted by the caller, wrapped server-side as a single-file bundle.\n\n' + - 'Each returned `skillId` can be passed via `managedRuntime.skills` on POST /agents or ' + - 'PATCH /agents/:identifier/runtime/config as `{ type: "custom", skillId }`. ' + - 'Re-uploading a source whose derived display title matches an existing custom skill appends a new ' + - 'version to it rather than failing — the entry returns the existing `skillId` and the new `version`. ' + - 'When a multi-skill `github-repo` upload partially fails, the request is aborted at the first ' + - 'error and earlier successful uploads are NOT rolled back (they will auto-version on retry).', - }) - @ApiNotFoundResponse({ description: 'The integration was not found.' }) - @RequirePermissions(PermissionsEnum.AGENT_WRITE) - @UseFilters(AgentRuntimeExceptionFilter) - createCustomSkill( - @UserSession() user: UserSessionData, - @Body() body: UploadCustomSkillRequestDto - ): Promise { - return this.uploadCustomSkillUsecase.execute( - UploadCustomSkillCommand.create({ - userId: user._id, - environmentId: user.environmentId, - organizationId: user.organizationId, - integrationId: body.integrationId, - source: body.source, - }) - ); - } -} diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index 8c13f1170e1..467a9ef7257 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -23,28 +23,42 @@ import { AuthModule } from '../auth/auth.module'; import { ChannelEndpointsModule } from '../channel-endpoints/channel-endpoints.module'; import { EventsModule } from '../events/events.module'; import { SharedModule } from '../shared/shared.module'; -import { AgentEmailActionsController } from './agent-email-actions.controller'; -import { AgentsController } from './agents.controller'; -import { AgentsMcpOAuthController } from './agents-mcp-oauth.controller'; -import { AgentsPublicController } from './agents-public.controller'; -import { AgentsWebhookController } from './agents-webhook.controller'; -import { AgentRuntimeExceptionFilter } from './filters/agent-runtime-exception.filter'; -import { AgentAttachmentStorage } from './services/agent-attachment-storage.service'; -import { AgentConfigResolver } from './services/agent-config-resolver.service'; -import { AgentConversationService } from './services/agent-conversation.service'; -import { AgentEmailActionTokenService } from './services/agent-email-action-token.service'; -import { AgentInboundHandler } from './services/agent-inbound-handler.service'; -import { AgentSubscriberResolver } from './services/agent-subscriber-resolver.service'; -import { BridgeExecutorService } from './services/bridge-executor.service'; -import { ChatSdkService } from './services/chat-sdk.service'; -import { DemoClaudeQuotaPolicy } from './services/demo-claude-quota-policy.service'; -import { ManagedAgentService } from './services/managed-agent.service'; -import { ManagedAgentEventHandler } from './services/managed-agent-event-handler'; -import { ManagedAgentProviderFactory } from './services/managed-agent-provider-factory'; -import { McpConnectionVaultService } from './services/mcp-connection-vault.service'; -import { McpOAuthDiscoveryService } from './services/mcp-oauth-discovery.service'; -import { TelegramMobileLinkTokenService } from './services/telegram-mobile-link-token.service'; -import { TelegramStartCodeService } from './services/telegram-start-code.service'; +import { AgentConfigResolver } from './channels/agent-config-resolver.service'; +import { AgentIntegrationsController } from './channels/integrations/agent-integrations.controller'; +import { AgentsPublicController } from './channels/telegram-linking/agents-public.controller'; +import { TelegramMobileLinkTokenService } from './channels/telegram-linking/telegram-mobile-link-token.service'; +import { TelegramStartCodeService } from './channels/telegram-linking/telegram-start-code.service'; +import { AgentAttachmentStorage } from './conversation-runtime/conversation/agent-attachment-storage.service'; +import { AgentConversationService } from './conversation-runtime/conversation/agent-conversation.service'; +import { AgentSubscriberResolver } from './conversation-runtime/conversation/agent-subscriber-resolver.service'; +import { FileMaterializer } from './conversation-runtime/egress/file-materializer.service'; +import { OutboundGateway } from './conversation-runtime/egress/outbound.gateway'; +import { AgentInboundController } from './conversation-runtime/ingress/agent-inbound.controller'; +import { ChatInstanceRegistry } from './conversation-runtime/ingress/chat-instance.registry'; +import { InboundDispatcher } from './conversation-runtime/ingress/inbound.dispatcher'; +import { AgentInboundHandler } from './conversation-runtime/ingress/inbound-turn.handler'; +import { AgentReplyController } from './conversation-runtime/reply/agent-reply.controller'; +import { BridgeRuntime } from './conversation-runtime/runtime/bridge.runtime'; +import { BridgeExecutorService } from './conversation-runtime/runtime/bridge-executor.service'; +import { RuntimeResolver } from './conversation-runtime/runtime/runtime-resolver.service'; +import { AgentEmailActionTokenService } from './email/agent-email-action-token.service'; +import { AgentEmailActionsController } from './email/agent-email-actions.controller'; +import { AgentEmailSender } from './email/agent-email-sender.service'; +import { NovuEmailCleanupService } from './email/novu-email/cleanup-novu-email/cleanup-novu-email.service'; +import { NovuEmailProvisioningService } from './email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service'; +import { DemoClaudeQuotaPolicy } from './managed-runtime/demo-claude-quota-policy.service'; +import { ManagedRuntime } from './managed-runtime/managed.runtime'; +import { ManagedAgentService } from './managed-runtime/managed-agent.service'; +import { ManagedAgentEventHandler } from './managed-runtime/managed-agent-event-handler.service'; +import { ManagedAgentProviderFactory } from './managed-runtime/managed-agent-provider-factory.service'; +import { ManagedRuntimeController } from './managed-runtime/managed-runtime.controller'; +import { AgentRuntimeController } from './management/agent-runtime.controller'; +import { AgentsController } from './management/agents.controller'; +import { McpNovuAppCredentialsService } from './mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service'; +import { McpConnectionVaultService } from './mcp/connections/mcp-connection-vault.service'; +import { AgentsMcpOAuthController } from './mcp/oauth/agents-mcp-oauth.controller'; +import { McpOAuthDiscoveryService } from './mcp/oauth/mcp-oauth-discovery.service'; +import { AgentRuntimeExceptionFilter } from './shared/agent-runtime-exception.filter'; import { USE_CASES } from './usecases'; @Module({ @@ -59,8 +73,12 @@ import { USE_CASES } from './usecases'; ], controllers: [ AgentsController, + AgentIntegrationsController, + AgentRuntimeController, AgentsPublicController, - AgentsWebhookController, + AgentInboundController, + AgentReplyController, + ManagedRuntimeController, AgentEmailActionsController, AgentsMcpOAuthController, ], @@ -83,12 +101,22 @@ import { USE_CASES } from './usecases'; AgentEmailActionTokenService, AgentInboundHandler, BridgeExecutorService, + BridgeRuntime, + ManagedRuntime, + RuntimeResolver, ManagedAgentProviderFactory, ManagedAgentEventHandler, ManagedAgentService, McpConnectionVaultService, + NovuEmailCleanupService, + NovuEmailProvisioningService, + McpNovuAppCredentialsService, DemoClaudeQuotaPolicy, - ChatSdkService, + ChatInstanceRegistry, + InboundDispatcher, + FileMaterializer, + AgentEmailSender, + OutboundGateway, McpOAuthDiscoveryService, TelegramMobileLinkTokenService, TelegramStartCodeService, @@ -98,6 +126,6 @@ import { USE_CASES } from './usecases'; UpdateSubscriber, UpdateSubscriberChannel, ], - exports: [...USE_CASES, ChatSdkService], + exports: [...USE_CASES, ChatInstanceRegistry, InboundDispatcher, OutboundGateway], }) export class AgentsModule {} diff --git a/apps/api/src/app/agents/services/agent-config-resolver.service.ts b/apps/api/src/app/agents/channels/agent-config-resolver.service.ts similarity index 95% rename from apps/api/src/app/agents/services/agent-config-resolver.service.ts rename to apps/api/src/app/agents/channels/agent-config-resolver.service.ts index e3c63a7f98c..72b0ac18e4d 100644 --- a/apps/api/src/app/agents/services/agent-config-resolver.service.ts +++ b/apps/api/src/app/agents/channels/agent-config-resolver.service.ts @@ -14,11 +14,11 @@ import { } from '@novu/dal'; import { EmailProviderIdEnum } from '@novu/shared'; import type { WellKnownEmoji } from 'chat'; -import { trackAgentIntegrationFirstWebhook } from '../agent-analytics'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { AgentInactiveException } from '../exceptions/agent-inactive.exception'; -import { esmImport } from '../utils/esm-import'; -import { resolveAgentPlatform } from '../utils/provider-to-platform'; +import { trackAgentIntegrationFirstWebhook } from '../shared/analytics/agent-analytics'; +import { AgentPlatformEnum } from '../shared/enums/agent-platform.enum'; +import { AgentInactiveException } from '../shared/errors/agent-inactive.exception'; +import { esmImport } from '../shared/util/esm-import'; +import { resolveAgentPlatform } from '../shared/util/provider-to-platform'; let cachedEmojiNames: Set | null = null; diff --git a/apps/api/src/app/agents/channels/index.ts b/apps/api/src/app/agents/channels/index.ts new file mode 100644 index 00000000000..7f72f0c38b5 --- /dev/null +++ b/apps/api/src/app/agents/channels/index.ts @@ -0,0 +1,3 @@ +export { AgentConfigResolver } from './agent-config-resolver.service'; +export { AgentIntegrationsController } from './integrations/agent-integrations.controller'; +export { AgentsPublicController } from './telegram-linking/agents-public.controller'; diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts b/apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.command.ts similarity index 78% rename from apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts rename to apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.command.ts index 12e38607f68..b5f4238ef4f 100644 --- a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.command.ts +++ b/apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class AddAgentIntegrationCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts b/apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.usecase.ts similarity index 94% rename from apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts rename to apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.usecase.ts index c21a09663ed..235a9ef80fe 100644 --- a/apps/api/src/app/agents/usecases/add-agent-integration/add-agent-integration.usecase.ts +++ b/apps/api/src/app/agents/channels/integrations/add-agent-integration/add-agent-integration.usecase.ts @@ -25,11 +25,10 @@ import { FeatureNameEnum, getFeatureForTierAsBoolean, } from '@novu/shared'; - -import { trackAgentIntegrationConnected } from '../../agent-analytics'; -import type { AgentIntegrationResponseDto } from '../../dtos'; -import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; -import { FindOrCreateNovuEmail } from '../find-or-create-novu-email/find-or-create-novu-email.usecase'; +import { NovuEmailProvisioningService } from '../../../email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service'; +import { trackAgentIntegrationConnected } from '../../../shared/analytics/agent-analytics'; +import type { AgentIntegrationResponseDto } from '../../../shared/dtos'; +import { toAgentIntegrationResponse } from '../../../shared/mappers/agent-response.mapper'; import { AddAgentIntegrationCommand } from './add-agent-integration.command'; @Injectable() @@ -40,7 +39,7 @@ export class AddAgentIntegration { private readonly agentIntegrationRepository: AgentIntegrationRepository, private readonly organizationRepository: CommunityOrganizationRepository, private readonly environmentRepository: EnvironmentRepository, - private readonly findOrCreateNovuEmail: FindOrCreateNovuEmail, + private readonly findOrCreateNovuEmail: NovuEmailProvisioningService, private readonly analyticsService: AnalyticsService ) {} diff --git a/apps/api/src/app/agents/channels/integrations/agent-integrations.controller.ts b/apps/api/src/app/agents/channels/integrations/agent-integrations.controller.ts new file mode 100644 index 00000000000..3212de3e7e3 --- /dev/null +++ b/apps/api/src/app/agents/channels/integrations/agent-integrations.controller.ts @@ -0,0 +1,453 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseInterceptors, +} from '@nestjs/common'; +import { ApiExcludeController, ApiExcludeEndpoint, ApiOperation } from '@nestjs/swagger'; +import { ProductFeature, RequirePermissions } from '@novu/application-generic'; +import { + ApiRateLimitCategoryEnum, + DirectionEnum, + PermissionsEnum, + ProductFeatureKeyEnum, + UserSessionData, +} from '@novu/shared'; +import { RequireAuthentication } from '../../../auth/framework/auth.decorator'; +import { ExternalApiAccessible } from '../../../auth/framework/external-api.decorator'; +import { ThrottlerCategory } from '../../../rate-limiting/guards'; +import { + ApiCommonResponses, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiResponse, +} from '../../../shared/framework/response.decorator'; +import { UserSession } from '../../../shared/framework/user.decorator'; +import { SendAgentWelcomeMessageCommand } from '../../conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.command'; +import { SendAgentWelcomeMessage } from '../../conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.usecase'; +import { SendAgentTestEmailCommand } from '../../email/send-agent-test-email/send-agent-test-email.command'; +import { SendAgentTestEmail } from '../../email/send-agent-test-email/send-agent-test-email.usecase'; +import { UpdateAgentInboxSharedCommand } from '../../management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command'; +import { UpdateAgentInboxShared } from '../../management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase'; +import { + AddAgentIntegrationRequestDto, + AgentIntegrationResponseDto, + ListAgentIntegrationsQueryDto, + ListAgentIntegrationsResponseDto, + UpdateAgentInboxSharedRequestDto, + UpdateAgentIntegrationRequestDto, +} from '../../shared/dtos'; +import { ConfigureTelegramWebhookResponseDto } from '../../shared/dtos/configure-telegram-webhook-response.dto'; +import { ConfigureWhatsAppWebhookResponseDto } from '../../shared/dtos/configure-whatsapp-webhook-response.dto'; +import { IssueTelegramMobileLinkRequestDto } from '../../shared/dtos/issue-telegram-mobile-link-request.dto'; +import { IssueTelegramMobileLinkResponseDto } from '../../shared/dtos/issue-telegram-mobile-link-response.dto'; +import { IssueTelegramSubscriberLinkRequestDto } from '../../shared/dtos/issue-telegram-subscriber-link-request.dto'; +import { IssueTelegramSubscriberLinkResponseDto } from '../../shared/dtos/issue-telegram-subscriber-link-response.dto'; +import { SendAgentTestEmailRequestDto } from '../../shared/dtos/send-agent-test-email-request.dto'; +import { SendAgentWelcomeMessageRequestDto } from '../../shared/dtos/send-agent-welcome-message-request.dto'; +import { + SendWhatsAppTestTemplateRequestDto, + SendWhatsAppTestTemplateResponseDto, +} from '../../shared/dtos/send-whatsapp-test-template.dto'; +import { ConfigureTelegramAgentWebhookCommand } from '../telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command'; +import { ConfigureTelegramAgentWebhook } from '../telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; +import { IssueTelegramMobileLinkCommand } from '../telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.command'; +import { IssueTelegramMobileLink } from '../telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase'; +import { IssueTelegramSubscriberLinkCommand } from '../telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command'; +import { IssueTelegramSubscriberLink } from '../telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; +import { ConfigureWhatsAppWebhookCommand } from '../whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.command'; +import { ConfigureWhatsAppWebhook } from '../whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase'; +import { SendWhatsAppTestTemplateCommand } from '../whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.command'; +import { SendWhatsAppTestTemplate } from '../whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase'; +import { AddAgentIntegrationCommand } from './add-agent-integration/add-agent-integration.command'; +import { AddAgentIntegration } from './add-agent-integration/add-agent-integration.usecase'; +import { ListAgentIntegrationsCommand } from './list-agent-integrations/list-agent-integrations.command'; +import { ListAgentIntegrations } from './list-agent-integrations/list-agent-integrations.usecase'; +import { RemoveAgentIntegrationCommand } from './remove-agent-integration/remove-agent-integration.command'; +import { RemoveAgentIntegration } from './remove-agent-integration/remove-agent-integration.usecase'; +import { UpdateAgentIntegrationCommand } from './update-agent-integration/update-agent-integration.command'; +import { UpdateAgentIntegration } from './update-agent-integration/update-agent-integration.usecase'; + +@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) +@ApiCommonResponses() +@Controller('/agents') +@UseInterceptors(ClassSerializerInterceptor) +@ApiExcludeController() +@RequireAuthentication() +export class AgentIntegrationsController { + constructor( + private readonly addAgentIntegrationUsecase: AddAgentIntegration, + private readonly listAgentIntegrationsUsecase: ListAgentIntegrations, + private readonly updateAgentIntegrationUsecase: UpdateAgentIntegration, + private readonly removeAgentIntegrationUsecase: RemoveAgentIntegration, + private readonly sendAgentTestEmailUsecase: SendAgentTestEmail, + private readonly sendAgentWelcomeMessageUsecase: SendAgentWelcomeMessage, + private readonly configureWhatsAppWebhookUsecase: ConfigureWhatsAppWebhook, + private readonly sendWhatsAppTestTemplateUsecase: SendWhatsAppTestTemplate, + private readonly configureTelegramAgentWebhookUsecase: ConfigureTelegramAgentWebhook, + private readonly issueTelegramMobileLinkUsecase: IssueTelegramMobileLink, + private readonly issueTelegramSubscriberLinkUsecase: IssueTelegramSubscriberLink, + private readonly updateAgentInboxSharedUsecase: UpdateAgentInboxShared + ) {} + + @Post('/:identifier/integrations') + @ExternalApiAccessible() + @ApiResponse(AgentIntegrationResponseDto, 201) + @ApiOperation({ + summary: 'Link integration to agent', + description: + 'Creates a link between an agent (by identifier) and an integration (by integration **identifier**, not the internal _id).', + }) + @ApiNotFoundResponse({ + description: 'The agent or integration was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + addAgentIntegration( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: AddAgentIntegrationRequestDto + ): Promise { + return this.addAgentIntegrationUsecase.execute( + AddAgentIntegrationCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationIdentifier: body.integrationIdentifier, + providerId: body.providerId, + }) + ); + } + + @Get('/:identifier/integrations') + @ExternalApiAccessible() + @ApiResponse(ListAgentIntegrationsResponseDto) + @ApiOperation({ + summary: 'List agent integrations', + description: + 'Lists integration links for an agent identified by its external identifier. Supports cursor pagination via **after**, **before**, **limit**, **orderBy**, and **orderDirection**.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + listAgentIntegrations( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Query() query: ListAgentIntegrationsQueryDto + ): Promise { + return this.listAgentIntegrationsUsecase.execute( + ListAgentIntegrationsCommand.create({ + user, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + limit: Number(query.limit || '10'), + after: query.after, + before: query.before, + orderDirection: query.orderDirection || DirectionEnum.DESC, + orderBy: query.orderBy || '_id', + includeCursor: query.includeCursor, + integrationIdentifier: query.integrationIdentifier, + }) + ); + } + + @Patch('/:identifier/integrations/:agentIntegrationId') + @ApiResponse(AgentIntegrationResponseDto) + @ApiOperation({ + summary: 'Update agent-integration link', + description: 'Updates which integration a link points to (by integration **identifier**, not the internal _id).', + }) + @ApiNotFoundResponse({ + description: 'The agent, integration, or link was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateAgentIntegration( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('agentIntegrationId') agentIntegrationId: string, + @Body() body: UpdateAgentIntegrationRequestDto + ): Promise { + return this.updateAgentIntegrationUsecase.execute( + UpdateAgentIntegrationCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + agentIntegrationId, + integrationIdentifier: body.integrationIdentifier, + }) + ); + } + + @Delete('/:identifier/integrations/:agentIntegrationId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Remove agent-integration link', + description: 'Deletes a specific agent-integration link by its document id.', + }) + @ApiNoContentResponse({ + description: 'The link was removed.', + }) + @ApiNotFoundResponse({ + description: 'The agent or agent-integration link was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + removeAgentIntegration( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('agentIntegrationId') agentIntegrationId: string + ): Promise { + return this.removeAgentIntegrationUsecase.execute( + RemoveAgentIntegrationCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + agentIntegrationId, + }) + ); + } + + @Post('/:identifier/integrations/:integrationIdentifier/whatsapp/auto-configure') + @ApiExcludeEndpoint() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Auto-configure the WhatsApp webhook for an agent integration', + description: + 'Calls Meta to register Novu as the webhook callback for the connected WhatsApp Business Account, subscribing to message events with the auto-generated verify token. Falls back to manual configuration when the access token lacks the management scope.', + }) + @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + configureAgentWhatsAppWebhook( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('integrationIdentifier') integrationIdentifier: string + ): Promise { + return this.configureWhatsAppWebhookUsecase.execute( + ConfigureWhatsAppWebhookCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationIdentifier, + }) + ); + } + + @Post('/:identifier/integrations/:integrationIdentifier/whatsapp/test-template') + @ApiExcludeEndpoint() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Send a hello_world WhatsApp template from the agent integration', + description: + 'Sends the standard `hello_world` template via the configured WhatsApp Business phone number to a recipient supplied by the user, used at the end of the onboarding flow to verify outbound delivery without asking the user to send an inbound message themselves.', + }) + @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + sendAgentWhatsAppTestTemplate( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('integrationIdentifier') integrationIdentifier: string, + @Body() body: SendWhatsAppTestTemplateRequestDto + ): Promise { + return this.sendWhatsAppTestTemplateUsecase.execute( + SendWhatsAppTestTemplateCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationIdentifier, + subscriberId: body.subscriberId, + }) + ); + } + + @Post('/:identifier/test-email') + @HttpCode(HttpStatus.OK) + @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) + @ApiOperation({ + summary: 'Send a test email to the agent inbound address', + description: + 'Sends a test email to the configured inbound address using the agent outbound provider (or the Novu demo integration as fallback). Used to verify the inbound email pipeline.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + sendAgentTestEmail( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: SendAgentTestEmailRequestDto + ): Promise<{ success: boolean }> { + return this.sendAgentTestEmailUsecase.execute( + SendAgentTestEmailCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + targetAddress: body.targetAddress, + }) + ); + } + + @Patch('/:identifier/inbox/shared') + @ApiResponse(AgentIntegrationResponseDto) + @ApiOperation({ + summary: 'Enable or disable the Novu shared inbox for an agent', + description: + 'Disabling drops inbound mail addressed to this agent on the shared `agentconnect.sh` domain — custom-domain ' + + 'routes continue to deliver. Refused when no custom-domain inbox is configured (would leave the agent with ' + + 'zero inbound paths).', + }) + @ApiNotFoundResponse({ description: 'The agent or its Novu Email integration was not found.' }) + @ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateAgentInboxShared( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: UpdateAgentInboxSharedRequestDto + ): Promise { + return this.updateAgentInboxSharedUsecase.execute( + UpdateAgentInboxSharedCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + disabled: body.disabled, + }) + ); + } + + @Post('/:identifier/welcome-message') + @ExternalApiAccessible() + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Send onboarding welcome message', + description: + 'Sends a proactive DM to the agent installer after Slack OAuth, or posts a bridge-connected ' + + 'follow-up message into an existing conversation thread when conversationId is supplied.', + }) + @ApiNotFoundResponse({ description: 'The agent or integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + sendAgentWelcomeMessage( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: SendAgentWelcomeMessageRequestDto + ): Promise<{ sent: boolean; conversationId?: string }> { + return this.sendAgentWelcomeMessageUsecase.execute( + SendAgentWelcomeMessageCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationIdentifier: body.integrationIdentifier, + conversationId: body.conversationId, + }) + ); + } + + @Post('/:identifier/integrations/:integrationId/telegram/configure') + @ExternalApiAccessible() + @HttpCode(HttpStatus.OK) + @ApiResponse(ConfigureTelegramWebhookResponseDto, 200) + @ApiOperation({ + summary: 'Configure Telegram bot webhook', + description: `Registers the Novu agent webhook URL with Telegram for the specified integration, + generates a cryptographic secret token for webhook verification, + and persists it on the integration. Re-running rotates the secret.`, + }) + @ApiNotFoundResponse({ + description: 'The agent, integration, or agent-integration link was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateTelegramWebhook( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('integrationId') integrationId: string + ): Promise { + return this.configureTelegramAgentWebhookUsecase.execute( + ConfigureTelegramAgentWebhookCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationId, + }) + ); + } + + @Post('/:identifier/integrations/:integrationId/telegram/mobile-link') + @ExternalApiAccessible() + @HttpCode(HttpStatus.OK) + @ApiResponse(IssueTelegramMobileLinkResponseDto, 200) + @ApiOperation({ + summary: 'Issue a short-lived Telegram mobile setup link', + description: + 'Issues a signed, single-use link (TTL = 5 minutes) that can be opened on a mobile device to finish ' + + 'configuring a Telegram bot without re-authenticating. Telegram-only.', + }) + @ApiNotFoundResponse({ + description: 'The agent, integration, or agent-integration link was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + createTelegramMobileLink( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('integrationId') integrationId: string, + @Body() body?: IssueTelegramMobileLinkRequestDto + ): Promise { + return this.issueTelegramMobileLinkUsecase.execute( + IssueTelegramMobileLinkCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationId, + subscriberId: body?.subscriberId, + }) + ); + } + + @Post('/:identifier/integrations/:integrationId/telegram/subscriber-link') + @ExternalApiAccessible() + @HttpCode(HttpStatus.OK) + @ApiResponse(IssueTelegramSubscriberLinkResponseDto, 200) + @ApiOperation({ + summary: 'Issue a Telegram subscriber-link deep link', + description: + 'Issues a short-lived opaque start code and returns a Telegram `t.me/?start=` deep link. When ' + + 'opened, Telegram sends `/start ` to the bot; the agent webhook consumes the code server-side and ' + + 'creates a `telegram_chat` channel endpoint so notifications can reach that subscriber via Telegram.', + }) + @ApiNotFoundResponse({ + description: 'The agent, integration, agent-integration link, or subscriber was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + createTelegramSubscriberLink( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('integrationId') integrationId: string, + @Body() body: IssueTelegramSubscriberLinkRequestDto + ): Promise { + return this.issueTelegramSubscriberLinkUsecase.execute( + IssueTelegramSubscriberLinkCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + integrationId, + subscriberId: body.subscriberId, + }) + ); + } +} diff --git a/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.command.ts b/apps/api/src/app/agents/channels/integrations/list-agent-integrations/list-agent-integrations.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.command.ts rename to apps/api/src/app/agents/channels/integrations/list-agent-integrations/list-agent-integrations.command.ts diff --git a/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts b/apps/api/src/app/agents/channels/integrations/list-agent-integrations/list-agent-integrations.usecase.ts similarity index 95% rename from apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts rename to apps/api/src/app/agents/channels/integrations/list-agent-integrations/list-agent-integrations.usecase.ts index d261e5352d0..e2a7b841a3f 100644 --- a/apps/api/src/app/agents/usecases/list-agent-integrations/list-agent-integrations.usecase.ts +++ b/apps/api/src/app/agents/channels/integrations/list-agent-integrations/list-agent-integrations.usecase.ts @@ -3,8 +3,8 @@ import { InstrumentUsecase, PinoLogger } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, IntegrationEntity, IntegrationRepository } from '@novu/dal'; import { DirectionEnum, EmailProviderIdEnum } from '@novu/shared'; -import { ListAgentIntegrationsResponseDto } from '../../dtos/list-agent-integrations-response.dto'; -import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import { ListAgentIntegrationsResponseDto } from '../../../shared/dtos/list-agent-integrations-response.dto'; +import { toAgentIntegrationResponse } from '../../../shared/mappers/agent-response.mapper'; import { ListAgentIntegrationsCommand } from './list-agent-integrations.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts b/apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.command.ts similarity index 74% rename from apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts rename to apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.command.ts index b250d390cb6..73d70b98884 100644 --- a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.command.ts +++ b/apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class RemoveAgentIntegrationCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts b/apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.usecase.ts similarity index 91% rename from apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts rename to apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.usecase.ts index 5bccf54288e..245b27ab5b3 100644 --- a/apps/api/src/app/agents/usecases/remove-agent-integration/remove-agent-integration.usecase.ts +++ b/apps/api/src/app/agents/channels/integrations/remove-agent-integration/remove-agent-integration.usecase.ts @@ -2,9 +2,8 @@ import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/commo import { AnalyticsService } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, EnvironmentRepository } from '@novu/dal'; import { EnvironmentTypeEnum } from '@novu/shared'; - -import { trackAgentIntegrationRemoved } from '../../agent-analytics'; -import { CleanupNovuEmail } from '../cleanup-novu-email/cleanup-novu-email.usecase'; +import { NovuEmailCleanupService } from '../../../email/novu-email/cleanup-novu-email/cleanup-novu-email.service'; +import { trackAgentIntegrationRemoved } from '../../../shared/analytics/agent-analytics'; import { RemoveAgentIntegrationCommand } from './remove-agent-integration.command'; @Injectable() @@ -13,7 +12,7 @@ export class RemoveAgentIntegration { private readonly agentRepository: AgentRepository, private readonly agentIntegrationRepository: AgentIntegrationRepository, private readonly environmentRepository: EnvironmentRepository, - private readonly cleanupNovuEmail: CleanupNovuEmail, + private readonly cleanupNovuEmail: NovuEmailCleanupService, private readonly analyticsService: AnalyticsService ) {} diff --git a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts b/apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.command.ts similarity index 78% rename from apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts rename to apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.command.ts index 1b662882cb1..821e2b4e7c3 100644 --- a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.command.ts +++ b/apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class UpdateAgentIntegrationCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts b/apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.usecase.ts similarity index 95% rename from apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts rename to apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.usecase.ts index 02061728ca9..402cfb516e7 100644 --- a/apps/api/src/app/agents/usecases/update-agent-integration/update-agent-integration.usecase.ts +++ b/apps/api/src/app/agents/channels/integrations/update-agent-integration/update-agent-integration.usecase.ts @@ -1,7 +1,7 @@ import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; -import type { AgentIntegrationResponseDto } from '../../dtos'; -import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationResponseDto } from '../../../shared/dtos'; +import { toAgentIntegrationResponse } from '../../../shared/mappers/agent-response.mapper'; import { UpdateAgentIntegrationCommand } from './update-agent-integration.command'; @Injectable() diff --git a/apps/api/src/app/agents/agents-public.controller.ts b/apps/api/src/app/agents/channels/telegram-linking/agents-public.controller.ts similarity index 76% rename from apps/api/src/app/agents/agents-public.controller.ts rename to apps/api/src/app/agents/channels/telegram-linking/agents-public.controller.ts index 9479ca6985d..db8e61d88fd 100644 --- a/apps/api/src/app/agents/agents-public.controller.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/agents-public.controller.ts @@ -2,20 +2,20 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post, Query } from '@nestj import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; import { ApiRateLimitCategoryEnum } from '@novu/shared'; -import { ThrottlerCategory } from '../rate-limiting/guards'; -import { ApiCommonResponses, ApiResponse } from '../shared/framework/response.decorator'; +import { ThrottlerCategory } from '../../../rate-limiting/guards'; +import { ApiCommonResponses, ApiResponse } from '../../../shared/framework/response.decorator'; import { ConsumeTelegramMobileLinkRequestDto, ConsumeTelegramMobileLinkResponseDto, -} from './dtos/consume-telegram-mobile-link.dto'; -import { TelegramMobileLinkStatusResponseDto } from './dtos/telegram-mobile-link-status-response.dto'; -import { ConsumeTelegramMobileLinkCommand } from './usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.command'; -import { ConsumeTelegramMobileLink } from './usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase'; -import { GetTelegramMobileLinkStatusCommand } from './usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.command'; +} from '../../shared/dtos/consume-telegram-mobile-link.dto'; +import { TelegramMobileLinkStatusResponseDto } from '../../shared/dtos/telegram-mobile-link-status-response.dto'; +import { ConsumeTelegramMobileLinkCommand } from './consume-telegram-mobile-link/consume-telegram-mobile-link.command'; +import { ConsumeTelegramMobileLink } from './consume-telegram-mobile-link/consume-telegram-mobile-link.usecase'; +import { GetTelegramMobileLinkStatusCommand } from './get-telegram-mobile-link-status/get-telegram-mobile-link-status.command'; import { GetTelegramMobileLinkStatus, type GetTelegramMobileLinkStatusResult, -} from './usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase'; +} from './get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase'; /** * Public, unauthenticated agent endpoints (no session). Add provider-specific diff --git a/apps/api/src/app/agents/usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.command.ts b/apps/api/src/app/agents/channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.command.ts rename to apps/api/src/app/agents/channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.command.ts diff --git a/apps/api/src/app/agents/usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts b/apps/api/src/app/agents/channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts similarity index 91% rename from apps/api/src/app/agents/usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts rename to apps/api/src/app/agents/channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts index a0393a88184..c448cd7d4ec 100644 --- a/apps/api/src/app/agents/usecases/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase.ts @@ -8,15 +8,11 @@ import { import { encryptSecret, PinoLogger } from '@novu/application-generic'; import { IntegrationRepository } from '@novu/dal'; import { ChatProviderIdEnum } from '@novu/shared'; - -import { - InvalidTelegramMobileTokenError, - TelegramMobileLinkTokenService, -} from '../../services/telegram-mobile-link-token.service'; -import { ConfigureTelegramAgentWebhookCommand } from '../configure-telegram-agent-webhook/configure-telegram-agent-webhook.command'; -import { ConfigureTelegramAgentWebhook } from '../configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; +import { ConfigureTelegramAgentWebhookCommand } from '../../telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command'; +import { ConfigureTelegramAgentWebhook } from '../../telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; import { IssueTelegramSubscriberLinkCommand } from '../issue-telegram-subscriber-link/issue-telegram-subscriber-link.command'; import { IssueTelegramSubscriberLink } from '../issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; +import { InvalidTelegramMobileTokenError, TelegramMobileLinkTokenService } from '../telegram-mobile-link-token.service'; import { ConsumeTelegramMobileLinkCommand } from './consume-telegram-mobile-link.command'; export interface ConsumeTelegramMobileLinkResult { diff --git a/apps/api/src/app/agents/usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.command.ts b/apps/api/src/app/agents/channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.command.ts rename to apps/api/src/app/agents/channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.command.ts diff --git a/apps/api/src/app/agents/usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts b/apps/api/src/app/agents/channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts similarity index 93% rename from apps/api/src/app/agents/usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts rename to apps/api/src/app/agents/channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts index 64aedd0e7ec..955d1fe26c5 100644 --- a/apps/api/src/app/agents/usecases/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase.ts @@ -2,10 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AgentRepository, IntegrationRepository } from '@novu/dal'; import { ChatProviderIdEnum } from '@novu/shared'; -import { - InvalidTelegramMobileTokenError, - TelegramMobileLinkTokenService, -} from '../../services/telegram-mobile-link-token.service'; +import { InvalidTelegramMobileTokenError, TelegramMobileLinkTokenService } from '../telegram-mobile-link-token.service'; import { GetTelegramMobileLinkStatusCommand } from './get-telegram-mobile-link-status.command'; export type GetTelegramMobileLinkStatusResult = diff --git a/apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts similarity index 78% rename from apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts rename to apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts index 5b6a302ba07..2b8527385bd 100644 --- a/apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class IssueTelegramMobileLinkCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts similarity index 96% rename from apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts rename to apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts index cd5ded43d42..096053e5e38 100644 --- a/apps/api/src/app/agents/usecases/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase.ts @@ -2,7 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { ChatProviderIdEnum } from '@novu/shared'; -import { TelegramMobileLinkTokenService } from '../../services/telegram-mobile-link-token.service'; +import { TelegramMobileLinkTokenService } from '../telegram-mobile-link-token.service'; import { IssueTelegramMobileLinkCommand } from './issue-telegram-mobile-link.command'; export interface IssueTelegramMobileLinkResult { diff --git a/apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts similarity index 77% rename from apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts rename to apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts index ff1283bbf12..189b8cb0744 100644 --- a/apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class IssueTelegramSubscriberLinkCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts rename to apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts index fb3aaa35334..09d5fe0dc05 100644 --- a/apps/api/src/app/agents/usecases/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase.ts @@ -15,7 +15,7 @@ import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } fr import { ChatProviderIdEnum } from '@novu/shared'; import Axios from 'axios'; -import { TelegramStartCodeService } from '../../services/telegram-start-code.service'; +import { TelegramStartCodeService } from '../telegram-start-code.service'; import { IssueTelegramSubscriberLinkCommand } from './issue-telegram-subscriber-link.command'; export interface IssueTelegramSubscriberLinkResult { diff --git a/apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts b/apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts similarity index 86% rename from apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts rename to apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts index 2dc791f1be6..de6c93b6b53 100644 --- a/apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import { EnvironmentCommand } from '../../../../shared/commands/project.command'; export class LinkTelegramChatToSubscriberCommand extends EnvironmentCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.spec.ts b/apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.spec.ts similarity index 100% rename from apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.spec.ts rename to apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.spec.ts diff --git a/apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts b/apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts similarity index 93% rename from apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts rename to apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts index ef7476ecf5c..b47ea78cb07 100644 --- a/apps/api/src/app/agents/usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts +++ b/apps/api/src/app/agents/channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase.ts @@ -8,8 +8,8 @@ import { SubscriberRepository, } from '@novu/dal'; import { ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/shared'; -import { CreateChannelEndpointCommand } from '../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command'; -import { CreateChannelEndpoint } from '../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; +import { CreateChannelEndpointCommand } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.command'; +import { CreateChannelEndpoint } from '../../../../channel-endpoints/usecases/create-channel-endpoint/create-channel-endpoint.usecase'; import { LinkTelegramChatToSubscriberCommand } from './link-telegram-chat-to-subscriber.command'; export interface LinkTelegramChatToSubscriberResult { diff --git a/apps/api/src/app/agents/services/telegram-mobile-link-token.service.ts b/apps/api/src/app/agents/channels/telegram-linking/telegram-mobile-link-token.service.ts similarity index 100% rename from apps/api/src/app/agents/services/telegram-mobile-link-token.service.ts rename to apps/api/src/app/agents/channels/telegram-linking/telegram-mobile-link-token.service.ts diff --git a/apps/api/src/app/agents/services/telegram-start-code.service.spec.ts b/apps/api/src/app/agents/channels/telegram-linking/telegram-start-code.service.spec.ts similarity index 100% rename from apps/api/src/app/agents/services/telegram-start-code.service.spec.ts rename to apps/api/src/app/agents/channels/telegram-linking/telegram-start-code.service.spec.ts diff --git a/apps/api/src/app/agents/services/telegram-start-code.service.ts b/apps/api/src/app/agents/channels/telegram-linking/telegram-start-code.service.ts similarity index 100% rename from apps/api/src/app/agents/services/telegram-start-code.service.ts rename to apps/api/src/app/agents/channels/telegram-linking/telegram-start-code.service.ts diff --git a/apps/api/src/app/agents/usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts b/apps/api/src/app/agents/channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts similarity index 74% rename from apps/api/src/app/agents/usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts rename to apps/api/src/app/agents/channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts index 48f90f7d557..530b56fa592 100644 --- a/apps/api/src/app/agents/usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts +++ b/apps/api/src/app/agents/channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class ConfigureTelegramAgentWebhookCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase.ts b/apps/api/src/app/agents/channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase.ts similarity index 100% rename from apps/api/src/app/agents/usecases/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase.ts rename to apps/api/src/app/agents/channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase.ts diff --git a/apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts b/apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts similarity index 74% rename from apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts rename to apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts index f2971629fe6..d4f9a877299 100644 --- a/apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts +++ b/apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class ConfigureWhatsAppWebhookCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts b/apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts similarity index 99% rename from apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts rename to apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts index 3afb7f9f4f9..6dc83ce515d 100644 --- a/apps/api/src/app/agents/usecases/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts +++ b/apps/api/src/app/agents/channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase.ts @@ -10,7 +10,7 @@ import { subscribeAppToWhatsAppEvents, subscribeWabaMessagesField, WHATSAPP_BUSINESS_MANAGEMENT_SCOPE, -} from '../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; +} from '../../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; import { ConfigureWhatsAppWebhookCommand } from './configure-whatsapp-webhook.command'; export type ConfigureWhatsAppWebhookFailure = { diff --git a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.command.ts b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.command.ts similarity index 77% rename from apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.command.ts rename to apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.command.ts index 07f25aa0c5e..dd8a00d3938 100644 --- a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.command.ts +++ b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class SendWhatsAppTestTemplateCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts similarity index 97% rename from apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts rename to apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts index 5a52e7b08f0..3e17ff258f4 100644 --- a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts +++ b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.spec.ts @@ -3,9 +3,9 @@ import { ChatProviderIdEnum } from '@novu/shared'; import { expect } from 'chai'; import { restore, stub } from 'sinon'; -import * as whatsappGraphApi from '../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; -import { SendWhatsAppTestTemplate } from './send-whatsapp-test-template.usecase'; +import * as whatsappGraphApi from '../../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; import { SendWhatsAppTestTemplateCommand } from './send-whatsapp-test-template.command'; +import { SendWhatsAppTestTemplate } from './send-whatsapp-test-template.usecase'; const ENV_ID = 'env-id'; const ORG_ID = 'org-id'; diff --git a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts rename to apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts index 258bafcbab5..361b70b674d 100644 --- a/apps/api/src/app/agents/usecases/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts +++ b/apps/api/src/app/agents/channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase.ts @@ -8,8 +8,8 @@ import { extractMetaError, type MetaErrorSummary, sendWhatsAppTemplate, -} from '../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; -import { normalizePhoneForMeta } from '../../utils/phone-normalization'; +} from '../../../../integrations/usecases/whatsapp/whatsapp-graph-api.utils'; +import { normalizePhoneForMeta } from '../../../shared/util/phone-normalization'; import { SendWhatsAppTestTemplateCommand } from './send-whatsapp-test-template.command'; const TEMPLATE_NAME = 'hello_world'; diff --git a/apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.spec.ts similarity index 99% rename from apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.spec.ts index 3ea14275435..ad320ba5987 100644 --- a/apps/api/src/app/agents/services/agent-attachment-storage.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.spec.ts @@ -2,7 +2,7 @@ import type { StorageService } from '@novu/application-generic'; import { expect } from 'chai'; import type { Attachment } from 'chat'; import sinon from 'sinon'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; import { AgentAttachmentStorage, READ_URL_TTL_SECONDS } from './agent-attachment-storage.service'; describe('AgentAttachmentStorage', () => { diff --git a/apps/api/src/app/agents/services/agent-attachment-storage.service.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.ts similarity index 98% rename from apps/api/src/app/agents/services/agent-attachment-storage.service.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.ts index 79c507ed7c7..7fbe0c3f8ab 100644 --- a/apps/api/src/app/agents/services/agent-attachment-storage.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-attachment-storage.service.ts @@ -1,8 +1,8 @@ import { Injectable } from '@nestjs/common'; import { PinoLogger, StorageService } from '@novu/application-generic'; import type { Attachment } from 'chat'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { captureAgentWarning } from '../utils/capture-agent-sentry'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; export interface StoredAttachment { type: string; diff --git a/apps/api/src/app/agents/services/agent-conversation.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts similarity index 100% rename from apps/api/src/app/agents/services/agent-conversation.service.spec.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.spec.ts diff --git a/apps/api/src/app/agents/services/agent-conversation.service.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts similarity index 100% rename from apps/api/src/app/agents/services/agent-conversation.service.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-conversation.service.ts diff --git a/apps/api/src/app/agents/services/agent-subscriber-resolver.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.spec.ts similarity index 98% rename from apps/api/src/app/agents/services/agent-subscriber-resolver.service.spec.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.spec.ts index 4764f066437..650a8c95750 100644 --- a/apps/api/src/app/agents/services/agent-subscriber-resolver.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.spec.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; describe('AgentSubscriberResolver', () => { diff --git a/apps/api/src/app/agents/services/agent-subscriber-resolver.service.ts b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.ts similarity index 93% rename from apps/api/src/app/agents/services/agent-subscriber-resolver.service.ts rename to apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.ts index a3e8fb0a96f..08e6acff361 100644 --- a/apps/api/src/app/agents/services/agent-subscriber-resolver.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/conversation/agent-subscriber-resolver.service.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; import { ChannelEndpointRepository, SubscriberRepository } from '@novu/dal'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { isValidEmailForLookup, normalizeEmailForLookup } from '../utils/email-normalization'; -import { getPhoneLookupCandidates } from '../utils/phone-normalization'; -import { PLATFORM_ENDPOINT_CONFIG } from '../utils/platform-endpoint-config'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { isValidEmailForLookup, normalizeEmailForLookup } from '../../shared/util/email-normalization'; +import { getPhoneLookupCandidates } from '../../shared/util/phone-normalization'; +import { PLATFORM_ENDPOINT_CONFIG } from '../../shared/util/platform-endpoint-config'; export interface ResolveSubscriberParams { environmentId: string; diff --git a/apps/api/src/app/agents/conversation-runtime/egress/file-materializer.service.ts b/apps/api/src/app/agents/conversation-runtime/egress/file-materializer.service.ts new file mode 100644 index 00000000000..d6933988a7a --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/egress/file-materializer.service.ts @@ -0,0 +1,402 @@ +import * as dns from 'node:dns'; +import * as http from 'node:http'; +import * as https from 'node:https'; +import { BadRequestException, Injectable } from '@nestjs/common'; +import { assertSafeOutboundUrl, isPrivateIp, PinoLogger, SsrfBlockedError } from '@novu/application-generic'; +import type { FileRef, ReplyContentDto } from '../../shared/dtos/agent-reply-payload.dto'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; + +export type ChatSdkFile = Omit & { data?: Buffer }; +export type ChatSdkReplyContent = Omit & { files?: ChatSdkFile[] }; + +type MaterializedFile = ChatSdkFile & { size: number; source: 'data' | 'url' }; +type PinnedFileResponse = { + status: number; + statusText: string; + headers: http.IncomingHttpHeaders; + data: Buffer; +}; + +const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; +const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024; +const MAX_INLINE_AGGREGATE_FILE_BYTES = 5 * 1024 * 1024; +const MAX_FILE_BYTES = 25 * 1024 * 1024; +const MAX_FILES_PER_MESSAGE = 15; +const MAX_AGGREGATE_FILE_BYTES = 50 * 1024 * 1024; +const MAX_INLINE_FILE_BASE64_CHARS = 7_000_000; +const FILE_FETCH_TIMEOUT_MS = 10_000; +const MAX_FILE_FETCH_REDIRECTS = 3; +const SUPPORTED_FILE_PLATFORMS = new Set([ + AgentPlatformEnum.SLACK, + AgentPlatformEnum.TEAMS, + AgentPlatformEnum.WHATSAPP, +]); +const UNSUPPORTED_FILE_PLATFORMS = new Set([AgentPlatformEnum.EMAIL]); + +@Injectable() +export class FileMaterializer { + constructor(private readonly logger: PinoLogger) { + this.logger.setContext(this.constructor.name); + } + + async prepareContentForDelivery( + content: ReplyContentDto, + platform: string = AgentPlatformEnum.SLACK, + agentId?: string + ): Promise { + if (!content.files?.length) { + return content as ChatSdkReplyContent; + } + + if (UNSUPPORTED_FILE_PLATFORMS.has(platform)) { + this.logger.warn( + { + agentId, + platform, + droppedCount: content.files.length, + }, + 'Dropping outbound agent files because platform does not support attachments' + ); + + const { files: _files, ...withoutFiles } = content; + + return withoutFiles as ChatSdkReplyContent; + } + + if (!SUPPORTED_FILE_PLATFORMS.has(platform)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `File attachments are not supported on platform "${platform}".`, + }); + } + + if (content.files.length > MAX_FILES_PER_MESSAGE) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Too many attachments: maximum is ${MAX_FILES_PER_MESSAGE} files per message.`, + }); + } + + const files: ChatSdkFile[] = []; + let aggregateSize = 0; + let inlineAggregateSize = 0; + + for (const [index, file] of content.files.entries()) { + const materialized = await this.prepareFileForDelivery(file, index); + aggregateSize += materialized.size; + if (materialized.source === 'data') { + inlineAggregateSize += materialized.size; + } + + if (aggregateSize > MAX_AGGREGATE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Total attachment size exceeds ${this.formatBytes(MAX_AGGREGATE_FILE_BYTES)}.`, + }); + } + + if (inlineAggregateSize > MAX_INLINE_AGGREGATE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Total inline attachment size exceeds ${this.formatBytes(MAX_INLINE_AGGREGATE_FILE_BYTES)}. Use URLs for larger files.`, + }); + } + + const { size: _size, source: _source, ...chatSdkFile } = materialized; + files.push(chatSdkFile); + } + + return { + ...content, + files, + }; + } + + private async prepareFileForDelivery(file: FileRef, index: number): Promise { + const data = (file as { data?: unknown }).data; + const url = (file as { url?: unknown }).url; + + if (data !== undefined && data !== null) { + if (typeof data !== 'string') { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + const buffer = this.decodeBase64FileData(data, file, index); + const { url: _url, ...fileWithoutUrl } = file; + + return { + ...fileWithoutUrl, + data: buffer, + size: buffer.length, + source: 'data', + }; + } + + if (typeof url !== 'string') { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: provide a public HTTP(S) url or base64 data.`, + }); + } + + const fetched = await this.fetchFileUrl(url, file, index); + const { url: _url, ...fileWithoutUrl } = file; + + return { + ...fileWithoutUrl, + data: fetched.data, + mimeType: file.mimeType || fetched.mimeType, + size: fetched.data.length, + source: 'url', + }; + } + + private decodeBase64FileData(data: string, file: FileRef, index: number): Buffer { + const normalized = data.replace(/\s/g, ''); + const remainder = normalized.length % 4; + + if (normalized.length > MAX_INLINE_FILE_BASE64_CHARS) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, + }); + } + + if (!normalized || remainder === 1 || !BASE64_REGEX.test(normalized)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + const padded = remainder === 0 ? normalized : normalized.padEnd(normalized.length + (4 - remainder), '='); + const buffer = Buffer.from(padded, 'base64'); + + if (buffer.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, + }); + } + + if (buffer.length > MAX_INLINE_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, + }); + } + + return buffer; + } + + private async fetchFileUrl(url: string, file: FileRef, index: number): Promise<{ data: Buffer; mimeType?: string }> { + const response = await this.fetchValidatedFileUrl(url, file, index); + + if (response.status < 200 || response.status >= 300) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: ${response.status} ${response.statusText}`, + }); + } + + const contentLength = this.getHeader(response.headers, 'content-length'); + if (contentLength) { + const size = Number(contentLength); + if (Number.isFinite(size) && size > MAX_FILE_BYTES) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }); + } + } + + const data = response.data; + const mimeType = this.getHeader(response.headers, 'content-type'); + + return { data, mimeType }; + } + + private async fetchValidatedFileUrl(url: string, file: FileRef, index: number): Promise { + let currentUrl = url; + + for (let redirectCount = 0; redirectCount <= MAX_FILE_FETCH_REDIRECTS; redirectCount += 1) { + const ssrfError = await this.validateFileUrl(currentUrl); + if (ssrfError) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: ${ssrfError}`, + }); + } + + let response: PinnedFileResponse; + try { + response = await this.requestPinnedFileUrl(currentUrl, file, index); + } catch (err) { + if (err instanceof BadRequestException) { + throw err; + } + + const message = err instanceof Error ? err.message : String(err); + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: ${message}`, + }); + } + + if (response.status < 300 || response.status >= 400) { + return response; + } + + const location = this.getHeader(response.headers, 'location'); + if (!location) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: redirect response missing Location header.`, + }); + } + + currentUrl = new URL(location, currentUrl).toString(); + } + + throw new BadRequestException({ + error: 'attachment_failed', + message: `Failed to fetch file ${this.describeFile(file, index)}: too many redirects.`, + }); + } + + private async validateFileUrl(url: string): Promise { + try { + assertSafeOutboundUrl(url); + } catch (err) { + if (err instanceof SsrfBlockedError) { + return err.message; + } + throw err; + } + + return null; + } + + private async requestPinnedFileUrl(url: string, file: FileRef, index: number): Promise { + const parsed = new URL(url); + const address = await this.resolvePublicAddress(parsed, file, index); + const client = parsed.protocol === 'https:' ? https : http; + + return await new Promise((resolve, reject) => { + const request = client.request( + { + protocol: parsed.protocol, + hostname: address.address, + family: address.family, + port: parsed.port || undefined, + path: `${parsed.pathname}${parsed.search}`, + method: 'GET', + headers: { Host: parsed.host }, + servername: parsed.hostname, + timeout: FILE_FETCH_TIMEOUT_MS, + }, + (response) => { + const status = response.statusCode ?? 0; + const statusText = response.statusMessage ?? ''; + + if (status >= 300 && status < 400) { + response.resume(); + resolve({ status, statusText, headers: response.headers, data: Buffer.alloc(0) }); + + return; + } + + const contentLength = this.getHeader(response.headers, 'content-length'); + if (contentLength) { + const size = Number(contentLength); + if (Number.isFinite(size) && size > MAX_FILE_BYTES) { + response.destroy(); + reject( + new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }) + ); + + return; + } + } + + const chunks: Buffer[] = []; + let total = 0; + + response.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > MAX_FILE_BYTES) { + response.destroy( + new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, + }) + ); + + return; + } + + chunks.push(chunk); + }); + response.on('end', () => + resolve({ status, statusText, headers: response.headers, data: Buffer.concat(chunks, total) }) + ); + response.on('error', reject); + } + ); + + request.on('timeout', () => request.destroy(new Error('Request timed out'))); + request.on('error', reject); + request.end(); + }); + } + + private async resolvePublicAddress(parsed: URL, file: FileRef, index: number): Promise { + let addresses: dns.LookupAddress[]; + try { + addresses = await dns.promises.lookup(parsed.hostname, { all: true }); + } catch { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, + }); + } + + if (!addresses.length) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, + }); + } + + for (const { address } of addresses) { + if (isPrivateIp(address)) { + throw new BadRequestException({ + error: 'attachment_failed', + message: `Invalid file ${this.describeFile(file, index)} url: Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`, + }); + } + } + + return addresses[0]; + } + + private getHeader(headers: http.IncomingHttpHeaders, name: string): string | undefined { + const value = headers[name.toLowerCase()]; + + return Array.isArray(value) ? value[0] : value; + } + + private describeFile(file: FileRef, index: number): string { + return file.filename ? `"${file.filename}"` : `at index ${index}`; + } + + private formatBytes(bytes: number): string { + return `${Math.floor(bytes / (1024 * 1024))} MB`; + } +} diff --git a/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts new file mode 100644 index 00000000000..0eaca7ccb31 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/egress/outbound.gateway.ts @@ -0,0 +1,409 @@ +import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common'; +import { ConversationChannel } from '@novu/dal'; +import type { SentMessageInfo } from '@novu/framework'; +import type { AdapterPostableMessage, EmojiValue, PlanModel, Thread } from 'chat'; +import { AgentConfigResolver } from '../../channels/agent-config-resolver.service'; +import type { ReplyContentDto } from '../../shared/dtos/agent-reply-payload.dto'; +import { esmImport } from '../../shared/util/esm-import'; +import { AgentConversationService } from '../conversation/agent-conversation.service'; +import { ChatInstanceRegistry } from '../ingress/chat-instance.registry'; +import type { ChatSdkFile, ChatSdkReplyContent } from './file-materializer.service'; +import { FileMaterializer } from './file-materializer.service'; + +export interface ConversationTarget { + agentId: string; + integrationIdentifier: string; + platform: string; + platformThreadId: string; +} + +export interface OutboundPersistContext { + conversationId: string; + channel: ConversationChannel; + agentIdentifier: string; + agentName?: string; + environmentId: string; + organizationId: string; +} + +export type OutboundMessage = ReplyContentDto; + +/** + * Persist context for a fallback reply posted on the live inbound thread. + * Content is passed explicitly (not derived from the message) because fallbacks + * persist human-readable text even when the posted payload is a card. + */ +export interface ThreadReplyPersistContext { + conversationId: string; + channel: ConversationChannel; + agentIdentifier: string; + content: string; + richContent?: Record; + environmentId: string; + organizationId: string; +} + +function getErrorResponseBody(err: unknown): unknown { + if (!err || typeof err !== 'object') { + return undefined; + } + + return (err as { response?: { body?: unknown } }).response?.body; +} + +function getDeliveryErrorDetail(body: unknown): string | undefined { + if (!body || typeof body !== 'object') { + return undefined; + } + + const responseBody = body as { errors?: Array<{ message?: unknown }>; message?: unknown }; + const firstErrorMessage = responseBody.errors?.[0]?.message; + if (typeof firstErrorMessage === 'string') { + return firstErrorMessage; + } + + return typeof responseBody.message === 'string' ? responseBody.message : undefined; +} + +function toDeliveryError(err: unknown): never { + const base = err instanceof Error ? err.message : String(err); + const detail = getDeliveryErrorDetail(getErrorResponseBody(err)); + + throw new BadGatewayException({ + error: 'delivery_failed', + message: detail ? `${base}: ${detail}` : base, + }); +} + +@Injectable() +export class OutboundGateway { + constructor( + private readonly registry: ChatInstanceRegistry, + private readonly conversation: AgentConversationService, + private readonly agentConfigResolver: AgentConfigResolver, + private readonly fileMaterializer: FileMaterializer + ) {} + + async deliver( + target: ConversationTarget, + msg: OutboundMessage, + persist: OutboundPersistContext + ): Promise { + const sent = await this.postToConversation( + target.agentId, + target.integrationIdentifier, + target.platform, + target.platformThreadId, + msg + ); + await this.persistDelivered(persist, sent, msg); + + return sent; + } + + async edit( + target: ConversationTarget, + messageId: string, + msg: OutboundMessage, + persist: OutboundPersistContext + ): Promise { + const sent = await this.editInConversation( + target.agentId, + target.integrationIdentifier, + target.platform, + target.platformThreadId, + messageId, + msg + ); + await this.conversation.persistAgentEdit({ + conversationId: persist.conversationId, + channel: persist.channel, + platformThreadId: sent.platformThreadId || undefined, + platformMessageId: sent.messageId, + agentIdentifier: persist.agentIdentifier, + agentName: persist.agentName, + content: this.extractTextFallback(msg), + richContent: msg.card || msg.files?.length ? (msg as Record) : undefined, + environmentId: persist.environmentId, + organizationId: persist.organizationId, + }); + + return sent; + } + + async replyOnThread( + thread: Thread, + msg: OutboundMessage, + opts?: { failSoft?: boolean; persist?: ThreadReplyPersistContext } + ): Promise { + let sent: { id: string; threadId: string }; + try { + sent = await (thread as unknown as { post(arg: unknown): Promise<{ id: string; threadId: string }> }).post( + this.toThreadPostArg(msg) + ); + } catch (err) { + if (opts?.failSoft) { + return null; + } + + throw err; + } + + if (opts?.persist) { + await this.conversation.persistAgentMessage({ + conversationId: opts.persist.conversationId, + channel: opts.persist.channel, + platformMessageId: sent.id, + agentIdentifier: opts.persist.agentIdentifier, + content: opts.persist.content, + richContent: opts.persist.richContent, + environmentId: opts.persist.environmentId, + organizationId: opts.persist.organizationId, + }); + } + + return { messageId: sent.id, platformThreadId: sent.threadId }; + } + + async postToConversation( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + content: ReplyContentDto + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const thread = chat.thread(platformThreadId); + const deliveryContent = await this.fileMaterializer.prepareContentForDelivery(content, platform, agentId); + + const postArg = this.buildAdapterPostableMessage(deliveryContent); + + const sent = await thread.post(postArg).catch(toDeliveryError); + + return { messageId: sent.id, platformThreadId: sent.threadId }; + } + + async startTypingInConversation( + agentId: string, + integrationIdentifier: string, + platformThreadId: string, + status = 'Thinking...' + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + const thread = chat.thread(platformThreadId); + + if (typeof thread.startTyping !== 'function') { + return; + } + + await thread.startTyping(status).catch(toDeliveryError); + } + + async sendDirectMessage( + agentId: string, + integrationIdentifier: string, + platformUserId: string, + content: ReplyContentDto + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const dmThread = await chat.openDM(platformUserId); + const deliveryContent = await this.fileMaterializer.prepareContentForDelivery(content, config.platform, agentId); + + const postArg = this.buildAdapterPostableMessage(deliveryContent); + + const sent = await dmThread.post(postArg).catch(toDeliveryError); + + const platformThreadId = sent.threadId.endsWith(':') ? `${sent.threadId}${sent.id}` : sent.threadId; + + return { messageId: sent.id, platformThreadId }; + } + + async editInConversation( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + content: ReplyContentDto + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const adapter = chat.getAdapter(platform); + if (typeof adapter.editMessage !== 'function') { + throw new BadRequestException(`Platform ${platform} does not support editing messages`); + } + + const deliveryContent = await this.fileMaterializer.prepareContentForDelivery(content, platform, agentId); + + const editPayload = this.buildAdapterPostableMessage(deliveryContent); + + let editPromise: Promise<{ id: string; threadId: string }>; + if (deliveryContent.card) { + editPromise = adapter.editMessage( + platformThreadId, + platformMessageId, + deliveryContent.card as unknown as AdapterPostableMessage + ); + } else { + editPromise = adapter.editMessage(platformThreadId, platformMessageId, editPayload); + } + + const edited = await editPromise.catch(toDeliveryError); + + return { messageId: edited.id, platformThreadId: edited.threadId }; + } + + async postPlanObject( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + model: PlanModel + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const adapter = chat.getAdapter(platform); + if (typeof adapter.postObject !== 'function') { + return null; + } + + const sent = await adapter.postObject(platformThreadId, 'plan', model).catch(toDeliveryError); + + return { messageId: sent.id, platformThreadId: sent.threadId }; + } + + async editPlanObject( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + model: PlanModel + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const adapter = chat.getAdapter(platform); + if (typeof adapter.editObject !== 'function') { + return; + } + + await adapter.editObject(platformThreadId, platformMessageId, 'plan', model).catch(toDeliveryError); + } + + async reactToMessage( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + emojiName: string + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const adapter = chat.getAdapter(platform); + const resolved = await this.resolveEmoji(emojiName); + await adapter.addReaction(platformThreadId, platformMessageId, resolved); + } + + async removeReaction( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + emojiName: string + ): Promise { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + const instanceKey = `${agentId}:${integrationIdentifier}`; + const chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + const adapter = chat.getAdapter(platform); + const resolved = await this.resolveEmoji(emojiName); + await adapter.removeReaction(platformThreadId, platformMessageId, resolved); + } + + private async resolveEmoji(name: string): Promise { + const { getEmoji } = await esmImport('chat'); + const resolved = getEmoji(name); + if (!resolved) { + throw new Error(`Unknown emoji name: "${name}". Use GET /agents/emoji to list supported options.`); + } + + return resolved; + } + + private buildAdapterPostableMessage(deliveryContent: ChatSdkReplyContent): AdapterPostableMessage { + if (deliveryContent.card) { + const payload: { card: unknown; files?: ChatSdkFile[] } = { + card: deliveryContent.card, + }; + + if (deliveryContent.files?.length) { + payload.files = deliveryContent.files; + } + + return payload as unknown as AdapterPostableMessage; + } + + return { + markdown: deliveryContent.markdown ?? '', + files: deliveryContent.files, + } as unknown as AdapterPostableMessage; + } + + private async persistDelivered( + persist: OutboundPersistContext, + sent: SentMessageInfo, + msg: OutboundMessage + ): Promise { + await this.conversation.persistAgentMessage({ + conversationId: persist.conversationId, + channel: persist.channel, + platformThreadId: sent.platformThreadId || undefined, + platformMessageId: sent.messageId, + agentIdentifier: persist.agentIdentifier, + agentName: persist.agentName, + content: this.extractTextFallback(msg), + richContent: msg.card || msg.files?.length ? (msg as Record) : undefined, + environmentId: persist.environmentId, + organizationId: persist.organizationId, + }); + } + + private extractTextFallback(msg: OutboundMessage): string { + if (msg.markdown) { + return msg.markdown; + } + if (msg.card) { + const title = (msg.card as { title?: string }).title; + + return title ?? '[Card]'; + } + + return ''; + } + + private toThreadPostArg(msg: OutboundMessage): unknown { + if (msg.markdown && !msg.card) { + return msg.markdown; + } + + return msg.card ?? msg; + } +} diff --git a/apps/api/src/app/agents/conversation-runtime/index.ts b/apps/api/src/app/agents/conversation-runtime/index.ts new file mode 100644 index 00000000000..dd47379411e --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/index.ts @@ -0,0 +1,15 @@ +export { AgentAttachmentStorage } from './conversation/agent-attachment-storage.service'; +export { AgentConversationService } from './conversation/agent-conversation.service'; +export { AgentSubscriberResolver } from './conversation/agent-subscriber-resolver.service'; +export { FileMaterializer } from './egress/file-materializer.service'; +export { OutboundGateway } from './egress/outbound.gateway'; +export { AgentInboundController } from './ingress/agent-inbound.controller'; +export { ChatInstanceRegistry } from './ingress/chat-instance.registry'; +export { InboundDispatcher } from './ingress/inbound.dispatcher'; +export { AgentInboundHandler } from './ingress/inbound-turn.handler'; +export { AgentReplyController } from './reply/agent-reply.controller'; +export type { AgentRuntime } from './runtime/agent-runtime.port'; +export { BridgeRuntime } from './runtime/bridge.runtime'; +export { BridgeExecutorService } from './runtime/bridge-executor.service'; +export type { ConversationTurn } from './runtime/conversation-turn'; +export { RuntimeResolver } from './runtime/runtime-resolver.service'; diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/agent-inbound.controller.ts b/apps/api/src/app/agents/conversation-runtime/ingress/agent-inbound.controller.ts new file mode 100644 index 00000000000..c508d1025c6 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/ingress/agent-inbound.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, HttpCode, HttpException, HttpStatus, Param, Post, Req, Res } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { PinoLogger } from '@novu/application-generic'; +import { Request, Response } from 'express'; +import type { AgentConfigResolveSource } from '../../channels/agent-config-resolver.service'; +import { AgentInactiveException } from '../../shared/errors/agent-inactive.exception'; +import { InboundDispatcher } from './inbound.dispatcher'; + +@Controller('/agents') +@ApiExcludeController() +export class AgentInboundController { + constructor( + private inboundDispatcher: InboundDispatcher, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } + + @Get('/:agentId/webhook/:integrationIdentifier') + async handleWebhookVerification( + @Param('agentId') agentId: string, + @Param('integrationIdentifier') integrationIdentifier: string, + @Req() req: Request, + @Res() res: Response + ) { + return this.routeWebhook(agentId, integrationIdentifier, req, res, 'webhook_verification'); + } + + @Post('/:agentId/webhook/:integrationIdentifier') + @HttpCode(HttpStatus.OK) + async handleInboundWebhook( + @Param('agentId') agentId: string, + @Param('integrationIdentifier') integrationIdentifier: string, + @Req() req: Request, + @Res() res: Response + ) { + return this.routeWebhook(agentId, integrationIdentifier, req, res, 'webhook_message'); + } + + private async routeWebhook( + agentId: string, + integrationIdentifier: string, + req: Request, + res: Response, + source: AgentConfigResolveSource + ) { + try { + await this.inboundDispatcher.handleWebhook(agentId, integrationIdentifier, req, res, { source }); + } catch (err) { + if (err instanceof AgentInactiveException) { + // Return 200 to avoid retries by the delivery provider + res.status(HttpStatus.OK).json({}); + + return; + } + + if (err instanceof HttpException) { + res.status(err.getStatus()).json(err.getResponse()); + } else { + throw err; + } + } + } +} diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts b/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts new file mode 100644 index 00000000000..1141154d9ec --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/ingress/chat-instance.registry.ts @@ -0,0 +1,442 @@ +import { BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; +import { CacheService, PinoLogger } from '@novu/application-generic'; +import type { Chat, Message, ReactionEvent, Thread } from 'chat'; +import { LRUCache } from 'lru-cache'; +import { AgentConfigResolver, ResolvedAgentConfig } from '../../channels/agent-config-resolver.service'; +import { AgentEmailActionTokenService } from '../../email/agent-email-action-token.service'; +import { AgentEmailSender, resolveAgentEmailSenderName } from '../../email/agent-email-sender.service'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; +import { esmImport } from '../../shared/util/esm-import'; +import type { InboundReactionEvent } from './inbound-turn.handler'; + +export interface InboundCallbacks { + onMessage: (agentId: string, config: ResolvedAgentConfig, thread: Thread, message: Message) => Promise; + onAction: ( + agentId: string, + config: ResolvedAgentConfig, + thread: Thread, + action: import('@novu/framework').AgentAction, + userId: string + ) => Promise; + onReaction: (agentId: string, config: ResolvedAgentConfig, event: InboundReactionEvent) => Promise; +} + +/** + * Holds a cached Chat instance alongside a mutable pointer to the current + * resolved config. Event handlers registered via registerEventHandlers() close + * over this box instead of the config value, so updates to fields that the + * bridge executor and inbound handler read at event time (bridgeUrl, + * devBridgeUrl, devBridgeActive, acknowledgeOnReceived, reactionOnResolved) take + * effect on the next inbound event without rebuilding the Chat instance. + * + * adapterFingerprint captures fields that are baked into the platform adapter + * at construction (credentials + connectionAccessToken); when these change, + * the cached instance is dropped and rebuilt — see getOrCreate(). + */ +export interface CachedChat { + chat: Chat; + config: ResolvedAgentConfig; + adapterFingerprint: string; +} + +/** + * Extracts the recipient email address from an encoded email thread ID. The email adapter's + * ThreadResolver encodes thread IDs as `email::`; we + * reverse that here so the token claims can carry the recipient as the `platformUserId` used + * for subscriber resolution on the click handler side. + */ +function extractRecipientFromThreadId(threadId: string): string { + const parts = threadId.split(':'); + if (parts.length !== 3 || parts[0] !== 'email' || !parts[1]) { + throw new Error(`Cannot extract recipient from invalid email thread id: ${threadId}`); + } + + return decodeURIComponent(parts[1]); +} + +const MAX_CACHED_INSTANCES = 200; +const INSTANCE_TTL_MS = 1000 * 60 * 30; + +@Injectable() +export class ChatInstanceRegistry implements OnModuleDestroy { + readonly instances: LRUCache; + private readonly pendingCreations = new Map>(); + private inboundCallbacks: InboundCallbacks | null = null; + + constructor( + private readonly logger: PinoLogger, + private readonly cacheService: CacheService, + private readonly agentConfigResolver: AgentConfigResolver, + private readonly actionTokenService: AgentEmailActionTokenService, + private readonly agentEmailSender: AgentEmailSender + ) { + this.logger.setContext(this.constructor.name); + this.instances = new LRUCache({ + max: MAX_CACHED_INSTANCES, + ttl: INSTANCE_TTL_MS, + dispose: (cached, key) => { + cached.chat.shutdown().catch((err) => { + this.logger.error(err, `Failed to shut down evicted Chat instance ${key}`); + captureAgentException(err, { + component: 'chat-instance-registry', + operation: 'shutdown-evicted', + extra: { instanceKey: key }, + }); + }); + }, + }); + } + + registerInboundCallbacks(callbacks: InboundCallbacks): void { + this.inboundCallbacks = callbacks; + } + + async getOrCreate( + instanceKey: string, + agentId: string, + platform: AgentPlatformEnum, + config: ResolvedAgentConfig + ): Promise { + const freshFingerprint = this.adapterFingerprint(config); + const existing = this.instances.get(instanceKey); + + if (existing) { + if (existing.adapterFingerprint === freshFingerprint) { + existing.config = config; + + return existing.chat; + } + + this.instances.delete(instanceKey); + } + + const pendingKey = `${instanceKey}:${freshFingerprint}`; + const pending = this.pendingCreations.get(pendingKey); + if (pending) return pending; + + const creation = this.createAndCache(instanceKey, agentId, platform, config, freshFingerprint); + this.pendingCreations.set(pendingKey, creation); + + try { + return await creation; + } finally { + this.pendingCreations.delete(pendingKey); + } + } + + async onModuleDestroy() { + const shutdowns = [...this.instances.entries()].map(async ([key, cached]) => { + try { + await cached.chat.shutdown(); + } catch (err) { + this.logger.error(err, `Failed to shut down Chat instance ${key}`); + captureAgentException(err, { + component: 'chat-instance-registry', + operation: 'shutdown', + extra: { instanceKey: key }, + }); + } + }); + + await Promise.allSettled(shutdowns); + this.instances.clear(); + } + + private async createAndCache( + instanceKey: string, + agentId: string, + platform: AgentPlatformEnum, + config: ResolvedAgentConfig, + adapterFingerprint: string + ): Promise { + const chat = await this.createChatInstance(instanceKey, agentId, platform, config); + await chat.initialize(); + const cached: CachedChat = { chat, config, adapterFingerprint }; + this.registerEventHandlers(agentId, cached); + this.instances.set(instanceKey, cached); + + return chat; + } + + private adapterFingerprint(config: ResolvedAgentConfig): string { + const { platform, credentials: c, connectionAccessToken } = config; + + return JSON.stringify({ + platform, + signingSecret: c.signingSecret ?? null, + clientId: c.clientId ?? null, + secretKey: c.secretKey ?? null, + tenantId: c.tenantId ?? null, + apiToken: c.apiToken ?? null, + token: c.token ?? null, + phoneNumberIdentification: c.phoneNumberIdentification ?? null, + connectionAccessToken: connectionAccessToken ?? null, + outboundIntegrationId: c.outboundIntegrationId ?? null, + useFromAddressOverride: c.useFromAddressOverride ?? null, + fromAddressOverride: c.fromAddressOverride ?? null, + emailSlugPrefix: c.emailSlugPrefix ?? null, + inboxRoutingKey: c.inboxRoutingKey ?? null, + sharedInboxDisabled: c.sharedInboxDisabled ?? null, + senderName: c.senderName ?? null, + agentName: config.agentName, + }); + } + + private async createChatInstance( + instanceKey: string, + agentId: string, + platform: AgentPlatformEnum, + config: ResolvedAgentConfig + ): Promise { + const [{ Chat: ChatCtor }, { createIoRedisState }] = await Promise.all([ + esmImport('chat'), + esmImport('@chat-adapter/state-ioredis'), + ]); + + const adapters = await this.buildAdapters(agentId, platform, config); + const client = this.cacheService.client; + if (!client) { + throw new Error('Cache in-memory provider client is not available for Conversational SDK state adapter'); + } + + return new ChatCtor({ + userName: `novu-agent-${instanceKey}`, + adapters, + state: createIoRedisState({ + client, + keyPrefix: `novu:agent:${instanceKey}`, + logger: this.chatStateLogger(), + }), + logger: 'silent', + }); + } + + private chatStateLogger() { + return { + debug: (msg: string, ctx?: Record) => this.logger.debug(ctx ?? {}, msg), + info: (msg: string, ctx?: Record) => this.logger.info(ctx ?? {}, msg), + warn: (msg: string, ctx?: Record) => { + this.logger.warn(ctx ?? {}, msg); + if (ctx?.err) { + captureAgentWarning(ctx.err, { + component: 'chat-instance-registry', + operation: 'chat-state-warn', + extra: { message: msg }, + }); + } + }, + error: (msg: string, ctx?: Record) => { + this.logger.error(ctx ?? {}, msg); + if (ctx?.err) { + captureAgentException(ctx.err, { + component: 'chat-instance-registry', + operation: 'chat-state-error', + extra: { message: msg }, + }); + } + }, + }; + } + + private async buildAdapters( + agentId: string, + platform: AgentPlatformEnum, + config: ResolvedAgentConfig + ): Promise> { + const { credentials, connectionAccessToken } = config; + + switch (platform) { + case AgentPlatformEnum.SLACK: { + if (!connectionAccessToken || !credentials.signingSecret) { + throw new BadRequestException('Slack agent integration requires botToken and signingSecret credentials'); + } + + const { createSlackAdapter } = await esmImport('@chat-adapter/slack'); + + return { + slack: createSlackAdapter({ + botToken: connectionAccessToken, + signingSecret: credentials.signingSecret, + }), + }; + } + case AgentPlatformEnum.TEAMS: { + if (!credentials.clientId || !credentials.secretKey || !credentials.tenantId) { + throw new BadRequestException( + 'Teams agent integration requires appId, appPassword, and appTenantId credentials' + ); + } + + const { createTeamsAdapter } = await esmImport('@chat-adapter/teams'); + + return { + teams: createTeamsAdapter({ + appId: credentials.clientId, + appPassword: credentials.secretKey, + appTenantId: credentials.tenantId, + }), + }; + } + case AgentPlatformEnum.WHATSAPP: { + if ( + !credentials.apiToken || + !credentials.secretKey || + !credentials.token || + !credentials.phoneNumberIdentification + ) { + throw new BadRequestException( + 'WhatsApp agent integration requires accessToken, appSecret, verifyToken, and phoneNumberId credentials' + ); + } + + const { createWhatsAppAdapter } = await esmImport('@chat-adapter/whatsapp'); + + return { + whatsapp: createWhatsAppAdapter({ + accessToken: credentials.apiToken, + appSecret: credentials.secretKey, + verifyToken: credentials.token, + phoneNumberId: credentials.phoneNumberIdentification, + }), + }; + } + case AgentPlatformEnum.TELEGRAM: { + if (!credentials.apiToken || !credentials.token) { + throw new BadRequestException( + 'Telegram agent integration requires a Bot Token and a webhook secret token. ' + + 'Run the "Configure webhook" step to provision the webhook secret token before this integration can receive messages.' + ); + } + + const { createTelegramAdapter } = await esmImport('@chat-adapter/telegram'); + + return { + telegram: createTelegramAdapter({ + botToken: credentials.apiToken, + secretToken: credentials.token, + mode: 'webhook', + }), + }; + } + case AgentPlatformEnum.EMAIL: { + const { outboundIntegrationId } = credentials; + + if (!credentials.secretKey) { + throw new BadRequestException('Email agent integration requires secretKey credentials'); + } + + const { createNovuEmailAdapter } = await esmImport('@novu/chat-adapter-email'); + + return { + email: createNovuEmailAdapter({ + senderName: resolveAgentEmailSenderName(config), + signingSecret: credentials.secretKey, + sendEmail: this.agentEmailSender.buildSendEmailCallback(config, outboundIntegrationId), + actionUrlBuilder: async ({ threadId, messageId, actionId, value, label, style }) => { + const userIdentifier = extractRecipientFromThreadId(threadId); + const { url } = await this.actionTokenService.signActionToken({ + agentId, + agentIdentifier: config.agentIdentifier, + agentName: config.agentName, + integrationIdentifier: config.integrationIdentifier, + environmentId: config.environmentId, + organizationId: config.organizationId, + threadId, + messageId, + actionId, + value, + label, + style, + userIdentifier, + }); + + return url; + }, + }), + }; + } + default: + throw new BadRequestException(`Unsupported platform: ${platform}`); + } + } + + private registerEventHandlers(agentId: string, cached: CachedChat) { + if (!this.inboundCallbacks) { + this.logger.warn(`[agent:${agentId}] No inbound callbacks registered, skipping event handler setup`); + + return; + } + + const callbacks = this.inboundCallbacks; + + cached.chat.onNewMention(async (thread: Thread, message: Message) => { + try { + await thread.subscribe(); + await callbacks.onMessage(agentId, cached.config, thread, message); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling new mention`); + captureAgentException(err, { component: 'chat-instance-registry', operation: 'on-new-mention', agentId }); + } + }); + + cached.chat.onSubscribedMessage(async (thread: Thread, message: Message) => { + try { + await callbacks.onMessage(agentId, cached.config, thread, message); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling subscribed message`); + captureAgentException(err, { + component: 'chat-instance-registry', + operation: 'on-subscribed-message', + agentId, + }); + } + }); + + cached.chat.onAction(async (event) => { + try { + if (!event.thread) { + this.logger.warn(`[agent:${agentId}] Action received without thread context, skipping`); + + return; + } + + await callbacks.onAction( + agentId, + cached.config, + event.thread as Thread, + { + id: event.actionId, + value: event.value, + sourceMessageId: event.messageId, + }, + event.user.userId + ); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling action ${event.actionId}`); + captureAgentException(err, { + component: 'chat-instance-registry', + operation: 'on-action', + agentId, + extra: { actionId: event.actionId }, + }); + } + }); + + cached.chat.onReaction(async (event: ReactionEvent) => { + try { + await callbacks.onReaction(agentId, cached.config, { + emoji: event.emoji, + added: event.added, + messageId: event.messageId, + message: event.message, + thread: event.thread as Thread | undefined, + user: event.user, + }); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling reaction`); + captureAgentException(err, { component: 'chat-instance-registry', operation: 'on-reaction', agentId }); + } + }); + } +} diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.spec.ts similarity index 91% rename from apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts rename to apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.spec.ts index c06a3ac8308..c1e5360dac8 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.spec.ts +++ b/apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.spec.ts @@ -1,9 +1,13 @@ import { expect } from 'chai'; import sinon from 'sinon'; -import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { AgentInboundHandler } from './agent-inbound-handler.service'; -import { NoBridgeUrlError } from './bridge-executor.service'; +import { ManagedRuntime } from '../../managed-runtime/managed.runtime'; +import { AgentEventEnum } from '../../shared/enums/agent-event.enum'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { OutboundGateway } from '../egress/outbound.gateway'; +import { BridgeRuntime } from '../runtime/bridge.runtime'; +import { NoBridgeUrlError } from '../runtime/bridge-executor.service'; +import { RuntimeResolver } from '../runtime/runtime-resolver.service'; +import { AgentInboundHandler } from './inbound-turn.handler'; describe('AgentInboundHandler', () => { const config = { @@ -41,11 +45,13 @@ describe('AgentInboundHandler', () => { findTelegramEndpointByIdentity?: sinon.SinonStub; agentFindOne?: sinon.SinonStub; managedAgentSetupHandleInbound?: sinon.SinonStub; + subscriberFindById?: sinon.SinonStub; + subscriberResolve?: sinon.SinonStub; } = {} ) { const logger = makeLogger(); const subscriberResolver = { - resolve: sinon.stub().resolves(null), + resolve: overrides.subscriberResolve ?? sinon.stub().resolves(null), }; const conversationService = { createOrGetConversation: sinon.stub().resolves(conversation), @@ -60,15 +66,18 @@ describe('AgentInboundHandler', () => { execute: overrides.bridgeError ? sinon.stub().rejects(overrides.bridgeError) : sinon.stub().resolves(undefined), }; const subscriberRepository = { - findBySubscriberId: sinon.stub(), + findBySubscriberId: overrides.subscriberFindById ?? sinon.stub(), }; const managedAgentService = { dispatch: sinon.stub().resolves(undefined), }; + const handleManagedAgentSetupInbound = { + execute: overrides.managedAgentSetupHandleInbound ?? sinon.stub().resolves(false), + }; const confirmToolApproval = { execute: sinon.stub().resolves(undefined), }; - const chatSdkService = { + const inboundDispatcher = { registerInboundCallbacks: sinon.stub(), }; const agentRepository = { @@ -77,6 +86,32 @@ describe('AgentInboundHandler', () => { const environmentRepository = { findOne: sinon.stub().resolves(null), }; + const outboundGateway = { + replyOnThread: sinon.stub().callsFake(async (thread: { post: sinon.SinonStub }, msg: { markdown?: string }) => { + const result = await thread.post(msg.markdown ?? msg); + + return { messageId: result.id, platformThreadId: result.threadId }; + }), + }; + const bridgeRuntime = new BridgeRuntime( + bridgeExecutor as any, + outboundGateway as any, + conversationService as any, + environmentRepository as any, + logger as any + ); + const managedRuntime = new ManagedRuntime( + managedAgentService as any, + handleManagedAgentSetupInbound as any, + confirmToolApproval as any, + outboundGateway as any, + conversationService as any, + logger as any + ); + const runtimeResolver = { + resolve: (agent: { runtime?: string; managedRuntime?: unknown } | null) => + agent?.runtime === 'managed' && agent.managedRuntime ? managedRuntime : bridgeRuntime, + }; const analyticsService = { track: sinon.stub(), }; @@ -94,21 +129,15 @@ describe('AgentInboundHandler', () => { const channelEndpointRepository = { findByPlatformIdentity: overrides.findTelegramEndpointByIdentity ?? sinon.stub().resolves(null), }; - const handleManagedAgentSetupInbound = { - execute: overrides.managedAgentSetupHandleInbound ?? sinon.stub().resolves(false), - }; const handler = new AgentInboundHandler( logger as any, subscriberResolver as any, conversationService as any, - bridgeExecutor as any, - managedAgentService as any, - confirmToolApproval as any, - handleManagedAgentSetupInbound as any, - chatSdkService as any, + runtimeResolver as any, + inboundDispatcher as any, + outboundGateway as any, agentRepository as any, subscriberRepository as any, - environmentRepository as any, analyticsService as any, attachmentStorage as any, startCodeService as any, @@ -129,6 +158,7 @@ describe('AgentInboundHandler', () => { managedAgentService, agentRepository, subscriberRepository, + outboundGateway, }; } @@ -308,36 +338,16 @@ describe('AgentInboundHandler', () => { findByPlatformThread: sinon.stub().resolves(conversation), getHistory: sinon.stub().resolves([]), }; - const managedAgentService = { dispatch: sinon.stub().resolves(undefined) }; - const handleManagedAgentSetupInbound = { execute: setupInbound }; - const subscriberRepository = { - findBySubscriberId: sinon.stub().resolves({ subscriberId: 'sub-1' }), - }; - const agentRepository = { - findOne: sinon.stub().resolves({ + const { handler, handleManagedAgentSetupInbound, managedAgentService } = makeHandler({ + managedAgentSetupHandleInbound: setupInbound, + subscriberResolve: sinon.stub().resolves('sub-1'), + subscriberFindById: sinon.stub().resolves({ subscriberId: 'sub-1' }), + agentFindOne: sinon.stub().resolves({ _id: 'agent1', runtime: 'managed', managedRuntime: { providerId: 'anthropic', _integrationId: 'int1', externalAgentId: 'ext1' }, }), - }; - const handler = new AgentInboundHandler( - logger as any, - subscriberResolver as any, - conversationService as any, - { execute: sinon.stub().resolves(undefined) } as any, - managedAgentService as any, - { execute: sinon.stub().resolves(undefined) } as any, - handleManagedAgentSetupInbound as any, - { registerInboundCallbacks: sinon.stub() } as any, - agentRepository as any, - subscriberRepository as any, - { findOne: sinon.stub().resolves(null) } as any, - { track: sinon.stub() } as any, - { storeInbound: sinon.stub().resolves([]) } as any, - { consumeIfMatches: sinon.stub().resolves({ status: 'missing' }) } as any, - { findByPlatformIdentity: sinon.stub().resolves(null) } as any, - { execute: sinon.stub().resolves({ created: true }) } as any - ); + }); const thread = makeSlackDmThread(); const message = makeSlackDmMessage(); diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.ts similarity index 56% rename from apps/api/src/app/agents/services/agent-inbound-handler.service.ts rename to apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.ts index e1cf12096d6..d7c3fe71f2d 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/ingress/inbound-turn.handler.ts @@ -1,41 +1,37 @@ import { Injectable, NotFoundException, type OnModuleInit } from '@nestjs/common'; -import { - AnalyticsService, - DEMO_QUOTA_EXHAUSTED_REPLY, - DemoQuotaExhaustedError, - PinoLogger, -} from '@novu/application-generic'; +import { AnalyticsService, PinoLogger } from '@novu/application-generic'; import { AgentRepository, ChannelEndpointRepository, ConversationActivityEntity, ConversationActivitySenderTypeEnum, + ConversationEntity, ConversationParticipantTypeEnum, - EnvironmentRepository, SubscriberRepository, } from '@novu/dal'; import type { AgentAction } from '@novu/framework'; import { ENDPOINT_TYPES } from '@novu/shared'; -import type { CardChild, CardElement, EmojiValue, Message, Thread } from 'chat'; -import { trackAgentInboundAction, trackAgentInboundMessage, trackAgentInboundReaction } from '../agent-analytics'; -import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { AgentPlatformEnum, PLATFORMS_WITH_TYPING_INDICATOR } from '../dtos/agent-platform.enum'; -import { LinkTelegramChatToSubscriberCommand } from '../usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command'; -import { LinkTelegramChatToSubscriber } from '../usecases/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase'; -import { HandleManagedAgentSetupInbound } from '../usecases/managed-agent-setup/handle-managed-agent-setup-inbound.usecase'; -import { ManagedAgentSetupInboundCommand } from '../usecases/managed-agent-setup/managed-agent-setup-inbound.command'; -import { isLinkButtonActionId, parseToolApprovalActionId } from '../usecases/tool-approval/approval-card.builder'; -import { ConfirmToolApprovalCommand } from '../usecases/tool-approval/confirm-tool-approval.command'; -import { ConfirmToolApproval } from '../usecases/tool-approval/confirm-tool-approval.usecase'; -import { captureAgentException, captureAgentWarning } from '../utils/capture-agent-sentry'; -import { AgentAttachmentStorage, type StoredAttachment } from './agent-attachment-storage.service'; -import { ResolvedAgentConfig } from './agent-config-resolver.service'; -import { AgentConversationService, getInboundActivityPreview } from './agent-conversation.service'; -import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; -import { BridgeExecutorService, type BridgeReaction, NoBridgeUrlError } from './bridge-executor.service'; -import { ChatSdkService } from './chat-sdk.service'; -import { ManagedAgentService } from './managed-agent.service'; -import { TelegramStartCodeService } from './telegram-start-code.service'; +import type { EmojiValue, Message, Thread } from 'chat'; +import { ResolvedAgentConfig } from '../../channels/agent-config-resolver.service'; +import { LinkTelegramChatToSubscriberCommand } from '../../channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.command'; +import { LinkTelegramChatToSubscriber } from '../../channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase'; +import { TelegramStartCodeService } from '../../channels/telegram-linking/telegram-start-code.service'; +import { + trackAgentInboundAction, + trackAgentInboundMessage, + trackAgentInboundReaction, +} from '../../shared/analytics/agent-analytics'; +import { AgentEventEnum } from '../../shared/enums/agent-event.enum'; +import { AgentPlatformEnum, PLATFORMS_WITH_TYPING_INDICATOR } from '../../shared/enums/agent-platform.enum'; +import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; +import { AgentAttachmentStorage, type StoredAttachment } from '../conversation/agent-attachment-storage.service'; +import { AgentConversationService, getInboundActivityPreview } from '../conversation/agent-conversation.service'; +import { AgentSubscriberResolver } from '../conversation/agent-subscriber-resolver.service'; +import { OutboundGateway } from '../egress/outbound.gateway'; +import type { BridgeReaction } from '../runtime/bridge-executor.service'; +import type { ConversationTurn } from '../runtime/conversation-turn'; +import { RuntimeResolver } from '../runtime/runtime-resolver.service'; +import { InboundDispatcher } from './inbound.dispatcher'; /** * `/start ` is Telegram's deep-link mechanism. Telegram delivers it as @@ -51,6 +47,13 @@ function extractTelegramStartToken(text: string | undefined): string | null { return match ? match[1] : null; } +// Link buttons render with a `link-` prefixed action id. They open a URL client-side; +// the SDK still emits an inbound action for the click, but there is nothing to do +// server-side, so it is swallowed. Runtime-agnostic. +function isLinkButtonActionId(id: string | undefined): boolean { + return typeof id === 'string' && id.startsWith('link-'); +} + function extractTelegramChatId(thread: Thread): string | null { const raw = thread.channelId; if (!raw) return null; @@ -73,29 +76,6 @@ const SUBSCRIBER_LINK_WRONG_BOT_REPLY = const ACKNOWLEDGE_FALLBACK_EMOJI = 'eyes' as const; -const ONBOARDING_NO_BRIDGE_TEXT = - "I'm live but running on defaults. Connect your agent in the dashboard to customize how I respond."; - -function buildNoBridgeReply(dashboardUrl?: string): CardElement { - const children: CardChild[] = [{ type: 'text', content: ONBOARDING_NO_BRIDGE_TEXT }]; - - if (dashboardUrl) { - children.push( - { type: 'divider' }, - { - type: 'actions', - children: [{ type: 'link-button', label: 'Continue setup', url: dashboardUrl, style: 'primary' }], - } - ); - } - - return { type: 'card', children }; -} - -const BRIDGE_OFFLINE_REPLY_MARKDOWN = `*The agent is currently offline.* - -The agent is unavailable right now. Please try again later.`; - function asRecord(value: unknown): Record | undefined { if (!value || typeof value !== 'object') { return undefined; @@ -143,12 +123,6 @@ function getInboundPlatformThreadId(platform: AgentPlatformEnum, thread: Thread, return `${thread.id}${threadRoot}`; } -function applyPlatformThreadIdToThread(thread: Thread, platformThreadId: string) { - // Chat SDK currently gives top-level Slack DMs an empty-root thread id (`slack:D...:`). - // Patch the in-memory handle before posting fallback replies so Slack receives a real thread root. - (thread as unknown as { id: string }).id = platformThreadId; -} - function mapStoredAttachmentsFromRichContent(richContent?: Record): StoredAttachment[] { const rawAttachments = richContent?.attachments; @@ -218,14 +192,11 @@ export class AgentInboundHandler implements OnModuleInit { private readonly logger: PinoLogger, private readonly subscriberResolver: AgentSubscriberResolver, private readonly conversationService: AgentConversationService, - private readonly bridgeExecutor: BridgeExecutorService, - private readonly managedAgentService: ManagedAgentService, - private readonly confirmToolApproval: ConfirmToolApproval, - private readonly handleManagedAgentSetupInbound: HandleManagedAgentSetupInbound, - private readonly chatSdkService: ChatSdkService, + private readonly runtimeResolver: RuntimeResolver, + private readonly inboundDispatcher: InboundDispatcher, + private readonly outboundGateway: OutboundGateway, private readonly agentRepository: AgentRepository, private readonly subscriberRepository: SubscriberRepository, - private readonly environmentRepository: EnvironmentRepository, private readonly analyticsService: AnalyticsService, private readonly attachmentStorage: AgentAttachmentStorage, private readonly startCodeService: TelegramStartCodeService, @@ -236,7 +207,7 @@ export class AgentInboundHandler implements OnModuleInit { } onModuleInit() { - this.chatSdkService.registerInboundCallbacks({ + this.inboundDispatcher.registerInboundCallbacks({ onMessage: (agentId, config, thread, message) => this.handle(agentId, config, thread, message, AgentEventEnum.ON_MESSAGE), onAction: (agentId, config, thread, action, userId) => this.handleAction(agentId, config, thread, action, userId), @@ -251,38 +222,89 @@ export class AgentInboundHandler implements OnModuleInit { message: Message, event: AgentEventEnum ): Promise { - if (config.platform === AgentPlatformEnum.TELEGRAM) { - const startToken = extractTelegramStartToken(message.text); - if (startToken) { - const consumed = await this.handleTelegramSubscriberLink(agentId, config, thread, message, startToken); - if (consumed) { - return; - } - } + if (await this.consumeTelegramStartLink(agentId, config, thread, message)) { + return; } - const subscriberId = await this.subscriberResolver - .resolve({ - environmentId: config.environmentId, - organizationId: config.organizationId, - platform: config.platform, - platformUserId: message.author.userId, - integrationIdentifier: config.integrationIdentifier, - }) - .catch((err) => { - this.logger.warn(err, `[agent:${agentId}] Subscriber resolution failed, continuing without subscriber`); - captureAgentWarning(err, { component: 'agent-inbound-handler', operation: 'resolve-subscriber', agentId }); + const subscriberId = await this.resolveSubscriberId(agentId, config, message.author.userId, 'resolve-subscriber'); + const platformThreadId = getInboundPlatformThreadId(config.platform, thread, message); + const conversation = await this.openConversation(agentId, config, message, subscriberId, platformThreadId); - return null; - }); + const storedAttachments = await this.storeInboundAttachments(config, conversation, message); + const isFirstMessage = !this.conversationService.getPrimaryChannel(conversation).firstPlatformMessageId; + + await this.recordInboundMessage(agentId, config, conversation, message, { + subscriberId, + platformThreadId, + storedAttachments, + event, + isFirstMessage, + }); + + const [subscriber, history, agent] = await Promise.all([ + subscriberId + ? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId) + : Promise.resolve(null), + this.conversationService.getHistory(config.environmentId, conversation._id), + this.agentRepository.findOne({ _id: agentId, _environmentId: config.environmentId }, [ + '_id', + 'runtime', + 'managedRuntime', + ]), + ]); + + await this.acknowledgeReceipt(agentId, config, thread, message, isFirstMessage); + + const runtime = this.runtimeResolver.resolve(agent); + const turn: ConversationTurn = { + agentId, + agent: agent ?? { _id: agentId }, + config, + conversation, + subscriber, + history, + message, + event, + thread, + platformThreadId, + storedAttachments: message.attachments?.length ? storedAttachments : undefined, + }; + + await runtime.dispatch(turn); + } + + /** Telegram `/start ` is control input; when present it is always consumed here. */ + private async consumeTelegramStartLink( + agentId: string, + config: ResolvedAgentConfig, + thread: Thread, + message: Message + ): Promise { + if (config.platform !== AgentPlatformEnum.TELEGRAM) { + return false; + } + + const startToken = extractTelegramStartToken(message.text); + if (!startToken) { + return false; + } + return this.handleTelegramSubscriberLink(agentId, config, thread, message, startToken); + } + + private async openConversation( + agentId: string, + config: ResolvedAgentConfig, + message: Message, + subscriberId: string | null, + platformThreadId: string + ): Promise { const participantId = subscriberId ?? `${config.platform}:${message.author.userId}`; const participantType = subscriberId ? ConversationParticipantTypeEnum.SUBSCRIBER : ConversationParticipantTypeEnum.PLATFORM_USER; - const platformThreadId = getInboundPlatformThreadId(config.platform, thread, message); - const conversation = await this.conversationService.createOrGetConversation({ + return this.conversationService.createOrGetConversation({ environmentId: config.environmentId, organizationId: config.organizationId, agentId, @@ -294,23 +316,44 @@ export class AgentInboundHandler implements OnModuleInit { platformUserId: message.author.userId, firstMessageText: resolveInboundFirstMessageText(config.platform, message), }); + } - const senderType = subscriberId - ? ConversationActivitySenderTypeEnum.SUBSCRIBER - : ConversationActivitySenderTypeEnum.PLATFORM_USER; + private async storeInboundAttachments( + config: ResolvedAgentConfig, + conversation: ConversationEntity, + message: Message + ): Promise { + if (!message.attachments?.length) { + return undefined; + } - let storedAttachments: StoredAttachment[] | undefined; + return this.attachmentStorage.storeInbound(message.attachments, { + organizationId: config.organizationId, + environmentId: config.environmentId, + conversationId: String(conversation._id), + platformMessageId: message.id ?? `unknown-${Date.now()}`, + platform: config.platform, + }); + } - if (message.attachments?.length) { - storedAttachments = await this.attachmentStorage.storeInbound(message.attachments, { - organizationId: config.organizationId, - environmentId: config.environmentId, - conversationId: String(conversation._id), - platformMessageId: message.id ?? `unknown-${Date.now()}`, - platform: config.platform, - }); + /** Persist the inbound activity, emit analytics, and capture the first platform message id. */ + private async recordInboundMessage( + agentId: string, + config: ResolvedAgentConfig, + conversation: ConversationEntity, + message: Message, + context: { + subscriberId: string | null; + platformThreadId: string; + storedAttachments?: StoredAttachment[]; + event: AgentEventEnum; + isFirstMessage: boolean; } - + ): Promise { + const { subscriberId, platformThreadId, storedAttachments, event, isFirstMessage } = context; + const senderType = subscriberId + ? ConversationActivitySenderTypeEnum.SUBSCRIBER + : ConversationActivitySenderTypeEnum.PLATFORM_USER; const richContent = storedAttachments?.length ? { attachments: storedAttachments.map(({ type, name, mimeType, size, storageKey }) => ({ @@ -323,16 +366,13 @@ export class AgentInboundHandler implements OnModuleInit { } : undefined; - const primaryChannel = this.conversationService.getPrimaryChannel(conversation); - const isFirstMessage = !primaryChannel.firstPlatformMessageId; - await this.conversationService.persistInboundMessage({ conversationId: conversation._id, platform: config.platform, integrationId: config.integrationId, platformThreadId, senderType, - senderId: participantId, + senderId: subscriberId ?? `${config.platform}:${message.author.userId}`, senderName: message.author.fullName, content: message.text, richContent, @@ -372,162 +412,61 @@ export class AgentInboundHandler implements OnModuleInit { }); }); } + } - const [subscriber, history] = await Promise.all([ - subscriberId - ? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId) - : Promise.resolve(null), - this.conversationService.getHistory(config.environmentId, conversation._id), - ]); - - const agent = await this.agentRepository.findOne({ _id: agentId, _environmentId: config.environmentId }, [ - '_id', - 'runtime', - 'managedRuntime', - ]); - - const isManagedAgent = agent?.runtime === 'managed' && agent.managedRuntime; - - if (config.acknowledgeOnReceived) { - const supportsTyping = PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform); - - if (supportsTyping) { - await thread.startTyping('Thinking...'); - } else if (isFirstMessage && message.id) { - thread - .createSentMessageFromMessage(message) - .addReaction(ACKNOWLEDGE_FALLBACK_EMOJI) - .catch((err) => { - this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`); - captureAgentWarning(err, { - component: 'agent-inbound-handler', - operation: 'add-ack-reaction', - agentId, - }); - }); - } + /** Optimistic receipt signal (typing indicator, or a reaction fallback on the first message). */ + private async acknowledgeReceipt( + agentId: string, + config: ResolvedAgentConfig, + thread: Thread, + message: Message, + isFirstMessage: boolean + ): Promise { + if (!config.acknowledgeOnReceived) { + return; } - // Subscriber still owes MCP OAuth: hold this message, show the setup card, skip dispatch. - // After OAuth completes, CompleteManagedAgentSetup replays the held message. - if (isManagedAgent && subscriber && message.id) { - const parked = await this.handleManagedAgentSetupInbound.execute( - ManagedAgentSetupInboundCommand.create({ - userId: 'system', - environmentId: config.environmentId, - organizationId: config.organizationId, - conversationId: conversation._id, - agentId: agent._id, - subscriberId: subscriber.subscriberId, - agentIdentifier: config.agentIdentifier, - integrationIdentifier: config.integrationIdentifier, - platformMessageId: message.id, - }) - ); + if (PLATFORMS_WITH_TYPING_INDICATOR.has(config.platform)) { + await thread.startTyping('Thinking...'); - if (parked) { - return; - } + return; } - try { - if (isManagedAgent) { - await this.managedAgentService.dispatch( - { - config, - conversation, - subscriber, - userMessageText: message.text ?? '', - }, - agent - ); - } else { - await this.bridgeExecutor.execute({ - event, - config, - conversation, - subscriber, - history, - message, - platformContext: { threadId: platformThreadId, channelId: thread.channelId, isDM: thread.isDM }, - storedAttachments: message.attachments?.length ? storedAttachments : undefined, - onBridgeFailure: async () => { - applyPlatformThreadIdToThread(thread, platformThreadId); - const sent = await thread.post(BRIDGE_OFFLINE_REPLY_MARKDOWN); - const channel = this.conversationService.getPrimaryChannel(conversation); - await this.conversationService.persistAgentMessage({ - conversationId: conversation._id, - channel, - platformMessageId: (sent as { id?: string })?.id ?? '', - agentIdentifier: config.agentIdentifier, - content: BRIDGE_OFFLINE_REPLY_MARKDOWN, - environmentId: config.environmentId, - organizationId: config.organizationId, - }); - }, - }); - } - } catch (err) { - if (err instanceof DemoQuotaExhaustedError) { - applyPlatformThreadIdToThread(thread, platformThreadId); - const sent = await thread.post(DEMO_QUOTA_EXHAUSTED_REPLY); - const channel = this.conversationService.getPrimaryChannel(conversation); - await this.conversationService.persistAgentMessage({ - conversationId: conversation._id, - channel, - platformMessageId: (sent as { id?: string })?.id ?? '', - agentIdentifier: config.agentIdentifier, - content: DEMO_QUOTA_EXHAUSTED_REPLY, - environmentId: config.environmentId, - organizationId: config.organizationId, - }); - - return; - } - - if (err instanceof NoBridgeUrlError) { - applyPlatformThreadIdToThread(thread, platformThreadId); - - let dashboardUrl: string | undefined; - const dashboardBase = process.env.DASHBOARD_URL || process.env.FRONT_BASE_URL; - if (dashboardBase) { - try { - const environment = await this.environmentRepository.findOne({ _id: config.environmentId }); - if (environment?.identifier) { - dashboardUrl = `${dashboardBase}/env/${environment.identifier}/agents/${config.agentIdentifier}/overview`; - } - } catch (lookupErr) { - this.logger.warn( - lookupErr, - `[agent:${config.agentIdentifier}] Failed to resolve dashboard URL for no-bridge reply` - ); - captureAgentWarning(lookupErr, { - component: 'agent-inbound-handler', - operation: 'resolve-dashboard-url', - agentIdentifier: config.agentIdentifier, - }); - } - } - - const reply = buildNoBridgeReply(dashboardUrl); - const sent = await thread.post(reply); - const channel = this.conversationService.getPrimaryChannel(conversation); - await this.conversationService.persistAgentMessage({ - conversationId: conversation._id, - channel, - platformMessageId: (sent as { id?: string })?.id ?? '', - agentIdentifier: config.agentIdentifier, - content: ONBOARDING_NO_BRIDGE_TEXT, - richContent: { card: reply }, - environmentId: config.environmentId, - organizationId: config.organizationId, + if (isFirstMessage && message.id) { + thread + .createSentMessageFromMessage(message) + .addReaction(ACKNOWLEDGE_FALLBACK_EMOJI) + .catch((err) => { + this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`); + captureAgentWarning(err, { + component: 'agent-inbound-handler', + operation: 'add-ack-reaction', + agentId, + }); }); + } + } - return; - } + private async resolveSubscriberId( + agentId: string, + config: ResolvedAgentConfig, + platformUserId: string, + operation: string + ): Promise { + return this.subscriberResolver + .resolve({ + environmentId: config.environmentId, + organizationId: config.organizationId, + platform: config.platform, + platformUserId, + integrationIdentifier: config.integrationIdentifier, + }) + .catch((err) => { + this.logger.warn(err, `[agent:${agentId}] Subscriber resolution failed (${operation}), continuing without it`); + captureAgentWarning(err, { component: 'agent-inbound-handler', operation, agentId }); - throw err; - } + return null; + }); } /** @@ -616,7 +555,7 @@ export class AgentInboundHandler implements OnModuleInit { private async safePostInboundReply(thread: Thread, text: string, agentId: string, message: Message): Promise { try { - await thread.post(text); + await this.outboundGateway.replyOnThread(thread, { markdown: text }); } catch (err) { this.logger.warn( err, @@ -663,27 +602,7 @@ export class AgentInboundHandler implements OnModuleInit { const platformUserId = event.user?.userId; const subscriberId = platformUserId - ? await this.subscriberResolver - .resolve({ - environmentId: config.environmentId, - organizationId: config.organizationId, - platform: config.platform, - platformUserId, - integrationIdentifier: config.integrationIdentifier, - }) - .catch((err) => { - this.logger.warn( - err, - `[agent:${agentId}] Subscriber resolution failed for reaction, continuing without subscriber` - ); - captureAgentWarning(err, { - component: 'agent-inbound-handler', - operation: 'resolve-subscriber-reaction', - agentId, - }); - - return null; - }) + ? await this.resolveSubscriberId(agentId, config, platformUserId, 'resolve-subscriber-reaction') : null; const [subscriber, history] = await Promise.all([ @@ -716,20 +635,22 @@ export class AgentInboundHandler implements OnModuleInit { : undefined, }; - await this.bridgeExecutor.execute({ - event: AgentEventEnum.ON_REACTION, + const runtime = this.runtimeResolver.resolve(null); + const turn: ConversationTurn = { + agentId, + agent: { _id: agentId }, config, conversation, subscriber, history, message: null, - platformContext: { - threadId, - channelId: event.thread?.channelId ?? '', - isDM: event.thread?.isDM ?? false, - }, + event: AgentEventEnum.ON_REACTION, + thread: event.thread ?? ({ id: threadId, channelId: '', isDM: false } as Thread), + platformThreadId: threadId, reaction: reactionPayload, - }); + }; + + await runtime.dispatch(turn); } async handleAction( @@ -739,27 +660,7 @@ export class AgentInboundHandler implements OnModuleInit { action: AgentAction, userId: string ): Promise { - const subscriberId = await this.subscriberResolver - .resolve({ - environmentId: config.environmentId, - organizationId: config.organizationId, - platform: config.platform, - platformUserId: userId, - integrationIdentifier: config.integrationIdentifier, - }) - .catch((err) => { - this.logger.warn( - err, - `[agent:${agentId}] Subscriber resolution failed for action, continuing without subscriber` - ); - captureAgentWarning(err, { - component: 'agent-inbound-handler', - operation: 'resolve-subscriber-action', - agentId, - }); - - return null; - }); + const subscriberId = await this.resolveSubscriberId(agentId, config, userId, 'resolve-subscriber-action'); const participantId = subscriberId ?? `${config.platform}:${userId}`; const participantType = subscriberId @@ -790,75 +691,41 @@ export class AgentInboundHandler implements OnModuleInit { actionId: action.id, }); - // MCP Approve/Deny buttons (ids starting with mcp-approval:*) are handled here. - // Return early so these clicks are not forwarded to bridgeExecutor below. - const toolApproval = parseToolApprovalActionId(action.id); - - if (toolApproval) { - await this.confirmToolApproval.execute( - ConfirmToolApprovalCommand.create({ - userId: 'system', - environmentId: config.environmentId, - organizationId: config.organizationId, - conversationId: conversation._id, - agentIdentifier: config.agentIdentifier, - integrationIdentifier: config.integrationIdentifier, - agentId, - subscriberId: subscriberId ?? undefined, - platform: config.platform, - parsed: toolApproval, - sourceMessageId: action.sourceMessageId, - actionValue: action.value, - }) - ); - - return; - } - - // Managed agents do not use the self-hosted bridge and never configure bridgeUrl. - // Card interactions today are limited to Novu-internal flows only: - // • mcp-approval:* — Approve/Deny tool-use (handled above) - // • link-* — link-button opens url in the browser; chat SDK still - // emits onAction but no server-side handler is needed - // Generic button clicks (custom ids, user-defined cards) are not supported - // on managed runtime yet — there is no bridge onAction and no managed-runtime - // action router to resume the provider session. - // TODO: route general managed-agent button clicks through ManagedAgentService - // (e.g. resume parked session / dispatch to runtime) instead of no-oping here. + // Link buttons open a URL client-side; the SDK still emits an action for the + // click but there is nothing to handle server-side. Swallow it for every runtime. if (isLinkButtonActionId(action.id)) { return; } - const agent = await this.agentRepository.findOne({ _id: agentId, _environmentId: config.environmentId }, [ - '_id', - 'runtime', - 'managedRuntime', - ]); - - if (agent?.runtime === 'managed' && agent.managedRuntime) { - return; - } - - const [subscriber, history] = await Promise.all([ + // Everything else (incl. mcp-approval:* for managed) routes through the runtime, + // which owns its own action semantics. + const [subscriber, history, agent] = await Promise.all([ subscriberId ? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId) : Promise.resolve(null), this.conversationService.getHistory(config.environmentId, conversation._id), + this.agentRepository.findOne({ _id: agentId, _environmentId: config.environmentId }, [ + '_id', + 'runtime', + 'managedRuntime', + ]), ]); - await this.bridgeExecutor.execute({ - event: AgentEventEnum.ON_ACTION, + const runtime = this.runtimeResolver.resolve(agent); + const turn: ConversationTurn = { + agentId, + agent: agent ?? { _id: agentId }, config, conversation, subscriber, history, message: null, - platformContext: { - threadId: thread.id, - channelId: thread.channelId, - isDM: thread.isDM, - }, + event: AgentEventEnum.ON_ACTION, + thread, + platformThreadId: thread.id, action, - }); + }; + + await runtime.dispatch(turn); } } diff --git a/apps/api/src/app/agents/conversation-runtime/ingress/inbound.dispatcher.ts b/apps/api/src/app/agents/conversation-runtime/ingress/inbound.dispatcher.ts new file mode 100644 index 00000000000..9a3fff0dac6 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/ingress/inbound.dispatcher.ts @@ -0,0 +1,118 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { PinoLogger } from '@novu/application-generic'; +import type { Chat } from 'chat'; +import { Request as ExpressRequest, Response as ExpressResponse } from 'express'; +import { AgentConfigResolver, AgentConfigResolveSource } from '../../channels/agent-config-resolver.service'; +import type { AgentEmailActionClaims } from '../../email/agent-email-action-token.service'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { sendWebResponse, toWebRequest } from '../../shared/util/express-to-web-request'; +import { ChatInstanceRegistry, InboundCallbacks } from './chat-instance.registry'; + +/** + * Thrown by `InboundDispatcher.processEmailAction` when a failure is provably pre-dispatch — + * i.e. token validation, agent-config lookup, or chat/adapter setup failed before the chat + * SDK had a chance to invoke the agent's `onAction` handler. Callers can safely retry these + * via single-use token release. Any other error (including raw exceptions out of + * `chat.processAction`) MUST be treated as potentially post-dispatch and not replayed. + */ +export class AgentActionPreDispatchError extends Error { + readonly preDispatch = true as const; + + constructor(message: string, cause?: unknown) { + super(message); + this.name = 'AgentActionPreDispatchError'; + if (cause !== undefined) { + (this as { cause?: unknown }).cause = cause; + } + } +} + +@Injectable() +export class InboundDispatcher { + constructor( + private readonly logger: PinoLogger, + private readonly registry: ChatInstanceRegistry, + private readonly agentConfigResolver: AgentConfigResolver + ) { + this.logger.setContext(this.constructor.name); + } + + registerInboundCallbacks(callbacks: InboundCallbacks): void { + this.registry.registerInboundCallbacks(callbacks); + } + + async handleWebhook( + agentId: string, + integrationIdentifier: string, + req: ExpressRequest, + res: ExpressResponse, + options: { source: AgentConfigResolveSource } + ) { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier, { + source: options.source, + }); + const { platform } = config; + const instanceKey = `${agentId}:${integrationIdentifier}`; + + const chat = await this.registry.getOrCreate(instanceKey, agentId, platform, config); + const handler = chat.webhooks[platform]; + if (!handler) { + throw new BadRequestException(`Platform ${platform} not configured for agent ${agentId}`); + } + + const webRequest = toWebRequest(req); + const webResponse = await handler(webRequest); + + await sendWebResponse(webResponse, res); + } + + /** + * Dispatches a verified email-button click into the chat SDK so it flows through the same + * `chat.onAction` → `AgentInboundHandler.handleAction` → bridge `onAction` path that + * inbound platforms (Slack/Teams) already use. + */ + async processEmailAction(claims: AgentEmailActionClaims): Promise { + const { agentId, integrationIdentifier } = claims; + + let chat: Chat; + let emailAdapter: ReturnType; + try { + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); + + if (config.platform !== AgentPlatformEnum.EMAIL) { + throw new BadRequestException( + `Agent ${agentId} integration ${integrationIdentifier} is not configured for the email platform` + ); + } + + const instanceKey = `${agentId}:${integrationIdentifier}`; + chat = await this.registry.getOrCreate(instanceKey, agentId, config.platform, config); + + emailAdapter = chat.getAdapter(AgentPlatformEnum.EMAIL); + if (!emailAdapter) { + throw new BadRequestException(`Email adapter not available for agent ${agentId}`); + } + } catch (err) { + throw new AgentActionPreDispatchError('Failed to resolve agent context before dispatching email action', err); + } + + await chat.processAction( + { + adapter: emailAdapter, + actionId: claims.actionId, + value: claims.value, + messageId: claims.messageId, + threadId: claims.threadId, + user: { + userId: claims.userIdentifier, + userName: claims.userIdentifier, + fullName: claims.userIdentifier, + isBot: false, + isMe: false, + }, + raw: {}, + }, + undefined + ); + } +} diff --git a/apps/api/src/app/agents/conversation-runtime/reply/agent-reply.controller.ts b/apps/api/src/app/agents/conversation-runtime/reply/agent-reply.controller.ts new file mode 100644 index 00000000000..969b05f2c65 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/reply/agent-reply.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, HttpCode, HttpStatus, Param, Post } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import type { Signal } from '@novu/framework'; +import { UserSessionData } from '@novu/shared'; +import { RequireAuthentication } from '../../../auth/framework/auth.decorator'; +import { ExternalApiAccessible } from '../../../auth/framework/external-api.decorator'; +import { UserSession } from '../../../shared/framework/user.decorator'; +import { AgentReplyPayloadDto } from '../../shared/dtos/agent-reply-payload.dto'; +import { HandleAgentReplyCommand } from './handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from './handle-agent-reply/handle-agent-reply.usecase'; + +@Controller('/agents') +@ApiExcludeController() +export class AgentReplyController { + constructor(private handleAgentReplyUsecase: HandleAgentReply) {} + + @Post('/:agentId/reply') + @HttpCode(HttpStatus.OK) + @RequireAuthentication() + @ExternalApiAccessible() + async handleAgentReply( + @UserSession() user: UserSessionData, + @Param('agentId') agentId: string, + @Body() body: AgentReplyPayloadDto + ) { + return this.handleAgentReplyUsecase.execute( + HandleAgentReplyCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + conversationId: body.conversationId, + agentIdentifier: agentId, + integrationIdentifier: body.integrationIdentifier, + reply: body.reply, + edit: body.edit, + resolve: body.resolve, + signals: body.signals as Signal[], + addReactions: body.addReactions, + }) + ); + } +} diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts b/apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command.ts similarity index 88% rename from apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts rename to apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command.ts index 248e3d42edb..b4cb56f1f30 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command.ts @@ -2,8 +2,8 @@ import type { Signal } from '@novu/framework'; import type { PlanModel } from 'chat'; import { Type } from 'class-transformer'; import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { AddReactionPayloadDto, EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; +import { AddReactionPayloadDto, EditPayloadDto, ReplyContentDto } from '../../../shared/dtos/agent-reply-payload.dto'; export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase.ts similarity index 83% rename from apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts rename to apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase.ts index 73575d0200c..c9005b396b1 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase.ts @@ -10,16 +10,16 @@ import { } from '@novu/dal'; import type { SentMessageInfo, TriggerSignal } from '@novu/framework'; import { AddressingTypeEnum, type TriggerRecipientsPayload, TriggerRequestCategoryEnum } from '@novu/shared'; -import { ParseEventRequest, ParseEventRequestMulticastCommand } from '../../../events/usecases/parse-event-request'; -import { trackAgentReplyProcessed } from '../../agent-analytics'; -import { AgentEventEnum } from '../../dtos/agent-event.enum'; -import type { EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; -import { isValidMetadataSignalKey } from '../../dtos/agent-reply-payload.dto'; -import { AgentConfigResolver, ResolvedAgentConfig } from '../../services/agent-config-resolver.service'; -import type { MetadataOp } from '../../services/agent-conversation.service'; -import { AgentConversationService } from '../../services/agent-conversation.service'; -import { BridgeExecutorService } from '../../services/bridge-executor.service'; -import { ChatSdkService } from '../../services/chat-sdk.service'; +import { ParseEventRequest, ParseEventRequestMulticastCommand } from '../../../../events/usecases/parse-event-request'; +import { AgentConfigResolver, ResolvedAgentConfig } from '../../../channels/agent-config-resolver.service'; +import { trackAgentReplyProcessed } from '../../../shared/analytics/agent-analytics'; +import type { EditPayloadDto, ReplyContentDto } from '../../../shared/dtos/agent-reply-payload.dto'; +import { isValidMetadataSignalKey } from '../../../shared/dtos/agent-reply-payload.dto'; +import { AgentEventEnum } from '../../../shared/enums/agent-event.enum'; +import type { MetadataOp } from '../../conversation/agent-conversation.service'; +import { AgentConversationService } from '../../conversation/agent-conversation.service'; +import { OutboundGateway } from '../../egress/outbound.gateway'; +import { BridgeExecutorService } from '../../runtime/bridge-executor.service'; import { HandleAgentReplyCommand } from './handle-agent-reply.command'; @Injectable() @@ -27,13 +27,13 @@ export class HandleAgentReply { constructor( private readonly agentRepository: AgentRepository, private readonly subscriberRepository: SubscriberRepository, - private readonly chatSdkService: ChatSdkService, private readonly bridgeExecutor: BridgeExecutorService, private readonly agentConfigResolver: AgentConfigResolver, private readonly conversationService: AgentConversationService, private readonly logger: PinoLogger, private readonly parseEventRequest: ParseEventRequest, - private readonly analyticsService: AnalyticsService + private readonly analyticsService: AnalyticsService, + private readonly outboundGateway: OutboundGateway ) { this.logger.setContext(this.constructor.name); } @@ -99,7 +99,7 @@ export class HandleAgentReply { if (command.addReactions?.length) { await Promise.allSettled( command.addReactions.map((r) => - this.chatSdkService.reactToMessage( + this.outboundGateway.reactToMessage( conversation._agentId, command.integrationIdentifier, channel.platform, @@ -174,30 +174,23 @@ export class HandleAgentReply { content: ReplyContentDto, agentName?: string ): Promise { - const textFallback = this.extractTextFallback(content); - - const sent = await this.chatSdkService.postToConversation( - conversation._agentId, - command.integrationIdentifier, - channel.platform, - channel.platformThreadId, - content + return this.outboundGateway.deliver( + { + agentId: conversation._agentId, + integrationIdentifier: command.integrationIdentifier, + platform: channel.platform, + platformThreadId: channel.platformThreadId, + }, + content, + { + conversationId: conversation._id, + channel, + agentIdentifier: command.agentIdentifier, + agentName, + environmentId: command.environmentId, + organizationId: command.organizationId, + } ); - - await this.conversationService.persistAgentMessage({ - conversationId: conversation._id, - channel, - platformThreadId: sent.platformThreadId || undefined, - platformMessageId: sent.messageId, - agentIdentifier: command.agentIdentifier, - agentName, - content: textFallback, - richContent: content.card || content.files?.length ? (content as Record) : undefined, - environmentId: command.environmentId, - organizationId: command.organizationId, - }); - - return sent; } private async deliverEdit( @@ -207,32 +200,24 @@ export class HandleAgentReply { edit: EditPayloadDto, agentName?: string ): Promise { - const textFallback = this.extractTextFallback(edit.content); - - const sent = await this.chatSdkService.editInConversation( - conversation._agentId, - command.integrationIdentifier, - channel.platform, - channel.platformThreadId, + return this.outboundGateway.edit( + { + agentId: conversation._agentId, + integrationIdentifier: command.integrationIdentifier, + platform: channel.platform, + platformThreadId: channel.platformThreadId, + }, edit.messageId, - edit.content + edit.content, + { + conversationId: conversation._id, + channel, + agentIdentifier: command.agentIdentifier, + agentName, + environmentId: command.environmentId, + organizationId: command.organizationId, + } ); - - await this.conversationService.persistAgentEdit({ - conversationId: conversation._id, - channel, - platformThreadId: sent.platformThreadId || undefined, - platformMessageId: sent.messageId, - agentIdentifier: command.agentIdentifier, - agentName, - content: textFallback, - richContent: - edit.content.card || edit.content.files?.length ? (edit.content as Record) : undefined, - environmentId: command.environmentId, - organizationId: command.organizationId, - }); - - return sent; } private async deliverPlan( @@ -242,7 +227,7 @@ export class HandleAgentReply { plan: NonNullable ): Promise { if (plan.messageId) { - await this.chatSdkService.editPlanObject( + await this.outboundGateway.editPlanObject( conversation._agentId, command.integrationIdentifier, channel.platform, @@ -254,7 +239,7 @@ export class HandleAgentReply { return { messageId: plan.messageId, platformThreadId: channel.platformThreadId }; } - return this.chatSdkService.postPlanObject( + return this.outboundGateway.postPlanObject( conversation._agentId, command.integrationIdentifier, channel.platform, @@ -263,17 +248,6 @@ export class HandleAgentReply { ); } - private extractTextFallback(content: ReplyContentDto): string { - if (content.markdown) return content.markdown; - if (content.card) { - const title = (content.card as { title?: string }).title; - - return title ?? '[Card]'; - } - - return ''; - } - private async executeSignals( command: HandleAgentReplyCommand, conversation: ConversationEntity, @@ -440,7 +414,7 @@ export class HandleAgentReply { const firstMessageId = channel.firstPlatformMessageId; if (!firstMessageId || !config.acknowledgeOnReceived) return; - await this.chatSdkService.removeReaction( + await this.outboundGateway.removeReaction( conversation._agentId, config.integrationIdentifier, channel.platform, @@ -458,7 +432,7 @@ export class HandleAgentReply { const firstMessageId = channel.firstPlatformMessageId; if (!firstMessageId || !config.reactionOnResolved) return; - await this.chatSdkService.reactToMessage( + await this.outboundGateway.reactToMessage( conversation._agentId, config.integrationIdentifier, channel.platform, diff --git a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command.ts similarity index 88% rename from apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts rename to apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command.ts index 750de73f295..75ca1e1320a 100644 --- a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.command.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsObject, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export interface ToolProgressPayload { turnId: string; diff --git a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts similarity index 99% rename from apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts rename to apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts index 6ad2d067999..36831c8ef31 100644 --- a/apps/api/src/app/agents/usecases/handle-plan-progress/handle-plan-progress.usecase.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PinoLogger, shortId } from '@novu/application-generic'; import { ConversationActivityEntity, ConversationActivityRepository, type ConversationChannel } from '@novu/dal'; import type { PlanModel, PlanTaskStatus } from 'chat'; -import { AgentConversationService } from '../../services/agent-conversation.service'; +import { AgentConversationService } from '../../conversation/agent-conversation.service'; import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; import { HandlePlanProgressCommand, type ToolProgressPayload } from './handle-plan-progress.command'; diff --git a/apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.command.ts b/apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.command.ts similarity index 78% rename from apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.command.ts rename to apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.command.ts index 323b6540fe7..2882dfa060e 100644 --- a/apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.command.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class SendAgentWelcomeMessageCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.usecase.ts b/apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.usecase.ts similarity index 86% rename from apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.usecase.ts rename to apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.usecase.ts index 6e91d8c12aa..8514e3ed344 100644 --- a/apps/api/src/app/agents/usecases/send-agent-welcome-message/send-agent-welcome-message.usecase.ts +++ b/apps/api/src/app/agents/conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.usecase.ts @@ -6,12 +6,11 @@ import { ConversationParticipantTypeEnum, IntegrationRepository, } from '@novu/dal'; - -import { AgentPlatformEnum } from '../../dtos/agent-platform.enum'; -import { AgentConversationService } from '../../services/agent-conversation.service'; -import { ChatSdkService } from '../../services/chat-sdk.service'; -import { PLATFORM_ENDPOINT_CONFIG } from '../../utils/platform-endpoint-config'; -import { resolveAgentPlatform } from '../../utils/provider-to-platform'; +import { AgentPlatformEnum } from '../../../shared/enums/agent-platform.enum'; +import { PLATFORM_ENDPOINT_CONFIG } from '../../../shared/util/platform-endpoint-config'; +import { resolveAgentPlatform } from '../../../shared/util/provider-to-platform'; +import { AgentConversationService } from '../../conversation/agent-conversation.service'; +import { OutboundGateway } from '../../egress/outbound.gateway'; import { SendAgentWelcomeMessageCommand } from './send-agent-welcome-message.command'; function getWelcomeText(platform: AgentPlatformEnum): string { @@ -35,9 +34,9 @@ export class SendAgentWelcomeMessage { private readonly agentRepository: AgentRepository, private readonly integrationRepository: IntegrationRepository, private readonly channelEndpointRepository: ChannelEndpointRepository, - private readonly chatSdkService: ChatSdkService, private readonly conversationService: AgentConversationService, private readonly analyticsService: AnalyticsService, + private readonly outboundGateway: OutboundGateway, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -106,7 +105,7 @@ export class SendAgentWelcomeMessage { try { const welcomeText = getWelcomeText(platform); - const sent = await this.chatSdkService.sendDirectMessage( + const sent = await this.outboundGateway.sendDirectMessage( agent._id, command.integrationIdentifier, platformUserId, @@ -186,24 +185,23 @@ export class SendAgentWelcomeMessage { try { const text = "Setup complete — I'm listening! Drop me a message to see me in action."; - const sent = await this.chatSdkService.postToConversation( - agent._id, - command.integrationIdentifier, - channel.platform, - channel.platformThreadId, - { markdown: text } + await this.outboundGateway.deliver( + { + agentId: agent._id, + integrationIdentifier: command.integrationIdentifier, + platform: channel.platform, + platformThreadId: channel.platformThreadId, + }, + { markdown: text }, + { + conversationId: conversation._id, + channel, + agentIdentifier: command.agentIdentifier, + environmentId: command.environmentId, + organizationId: command.organizationId, + } ); - await this.conversationService.persistAgentMessage({ - conversationId: conversation._id, - channel, - platformMessageId: sent.messageId, - agentIdentifier: command.agentIdentifier, - content: text, - environmentId: command.environmentId, - organizationId: command.organizationId, - }); - this.analyticsService.track(`Agent Bridge Connected Message Sent - [Agents]`, command.userId, { _organization: command.organizationId, environmentId: command.environmentId, diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/agent-runtime.port.ts b/apps/api/src/app/agents/conversation-runtime/runtime/agent-runtime.port.ts new file mode 100644 index 00000000000..42692909557 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/runtime/agent-runtime.port.ts @@ -0,0 +1,5 @@ +import type { ConversationTurn } from './conversation-turn'; + +export interface AgentRuntime { + dispatch(turn: ConversationTurn): Promise; +} diff --git a/apps/api/src/app/agents/services/bridge-executor.service.spec.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts similarity index 100% rename from apps/api/src/app/agents/services/bridge-executor.service.spec.ts rename to apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.spec.ts diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts similarity index 98% rename from apps/api/src/app/agents/services/bridge-executor.service.ts rename to apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts index 2b92bf2a27f..6ea955331e9 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge-executor.service.ts @@ -23,9 +23,9 @@ import type { import { AgentEventEnum } from '@novu/framework'; import { HttpHeaderKeysEnum } from '@novu/framework/internal'; import type { Message } from 'chat'; -import { captureAgentException, captureAgentWarning } from '../utils/capture-agent-sentry'; -import { AgentAttachmentStorage, type StoredAttachment } from './agent-attachment-storage.service'; -import { ResolvedAgentConfig } from './agent-config-resolver.service'; +import { ResolvedAgentConfig } from '../../channels/agent-config-resolver.service'; +import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; +import { AgentAttachmentStorage, type StoredAttachment } from '../conversation/agent-attachment-storage.service'; const MAX_RETRIES = 2; diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/bridge.runtime.ts b/apps/api/src/app/agents/conversation-runtime/runtime/bridge.runtime.ts new file mode 100644 index 00000000000..b9f7ad07614 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/runtime/bridge.runtime.ts @@ -0,0 +1,142 @@ +import { Injectable } from '@nestjs/common'; +import { PinoLogger } from '@novu/application-generic'; +import { EnvironmentRepository } from '@novu/dal'; +import type { CardChild, CardElement } from 'chat'; +import { captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; +import { AgentConversationService } from '../conversation/agent-conversation.service'; +import { OutboundGateway } from '../egress/outbound.gateway'; +import type { AgentRuntime } from './agent-runtime.port'; +import { type AgentExecutionParams, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service'; +import type { ConversationTurn } from './conversation-turn'; +import { applyPlatformThreadIdToThread } from './platform-thread.util'; + +const BRIDGE_OFFLINE_REPLY_MARKDOWN = `*The agent is currently offline.* + +The agent is unavailable right now. Please try again later.`; + +const ONBOARDING_NO_BRIDGE_TEXT = + "I'm live but running on defaults. Connect your agent in the dashboard to customize how I respond."; + +function buildNoBridgeReply(dashboardUrl?: string): Record { + const children: CardChild[] = [{ type: 'text', content: ONBOARDING_NO_BRIDGE_TEXT }]; + + if (dashboardUrl) { + children.push( + { type: 'divider' }, + { + type: 'actions', + children: [{ type: 'link-button', label: 'Continue setup', url: dashboardUrl, style: 'primary' }], + } + ); + } + + const card: CardElement = { type: 'card', children }; + + return card as unknown as Record; +} + +@Injectable() +export class BridgeRuntime implements AgentRuntime { + constructor( + private readonly bridgeExecutor: BridgeExecutorService, + private readonly outboundGateway: OutboundGateway, + private readonly conversationService: AgentConversationService, + private readonly environmentRepository: EnvironmentRepository, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } + + /** Bridge handles every turn shape the same way: forward it to the customer bridge. */ + async dispatch(turn: ConversationTurn): Promise { + try { + await this.bridgeExecutor.execute(this.toExecutionParams(turn)); + } catch (err) { + if (err instanceof NoBridgeUrlError) { + await this.replyNoBridgeConfigured(turn); + + return; + } + + throw err; + } + } + + private toExecutionParams(turn: ConversationTurn): AgentExecutionParams { + return { + event: turn.event, + config: turn.config, + conversation: turn.conversation, + subscriber: turn.subscriber, + history: turn.history, + message: turn.message, + platformContext: { + threadId: turn.platformThreadId, + channelId: turn.thread.channelId, + isDM: turn.thread.isDM, + }, + storedAttachments: turn.storedAttachments, + action: turn.action, + reaction: turn.reaction, + onBridgeFailure: async () => { + applyPlatformThreadIdToThread(turn.thread, turn.platformThreadId); + await this.outboundGateway.replyOnThread( + turn.thread, + { markdown: BRIDGE_OFFLINE_REPLY_MARKDOWN }, + { + persist: { + conversationId: turn.conversation._id, + channel: this.conversationService.getPrimaryChannel(turn.conversation), + agentIdentifier: turn.config.agentIdentifier, + content: BRIDGE_OFFLINE_REPLY_MARKDOWN, + environmentId: turn.config.environmentId, + organizationId: turn.config.organizationId, + }, + } + ); + }, + }; + } + + private async replyNoBridgeConfigured(turn: ConversationTurn): Promise { + applyPlatformThreadIdToThread(turn.thread, turn.platformThreadId); + + let dashboardUrl: string | undefined; + const dashboardBase = process.env.DASHBOARD_URL || process.env.FRONT_BASE_URL; + if (dashboardBase) { + try { + const environment = await this.environmentRepository.findOne({ _id: turn.config.environmentId }); + if (environment?.identifier) { + dashboardUrl = `${dashboardBase}/env/${environment.identifier}/agents/${turn.config.agentIdentifier}/overview`; + } + } catch (lookupErr) { + this.logger.warn( + lookupErr, + `[agent:${turn.config.agentIdentifier}] Failed to resolve dashboard URL for no-bridge reply` + ); + captureAgentWarning(lookupErr, { + component: 'bridge-runtime', + operation: 'resolve-dashboard-url', + agentIdentifier: turn.config.agentIdentifier, + }); + } + } + + const reply = buildNoBridgeReply(dashboardUrl); + await this.outboundGateway.replyOnThread( + turn.thread, + { card: reply }, + { + persist: { + conversationId: turn.conversation._id, + channel: this.conversationService.getPrimaryChannel(turn.conversation), + agentIdentifier: turn.config.agentIdentifier, + content: ONBOARDING_NO_BRIDGE_TEXT, + richContent: { card: reply }, + environmentId: turn.config.environmentId, + organizationId: turn.config.organizationId, + }, + } + ); + } +} diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/conversation-turn.ts b/apps/api/src/app/agents/conversation-runtime/runtime/conversation-turn.ts new file mode 100644 index 00000000000..fb0b2d0c4e0 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/runtime/conversation-turn.ts @@ -0,0 +1,23 @@ +import type { AgentEntity, ConversationActivityEntity, ConversationEntity, SubscriberEntity } from '@novu/dal'; +import type { AgentAction } from '@novu/framework'; +import type { Message, Thread } from 'chat'; +import type { ResolvedAgentConfig } from '../../channels/agent-config-resolver.service'; +import type { AgentEventEnum } from '../../shared/enums/agent-event.enum'; +import type { StoredAttachment } from '../conversation/agent-attachment-storage.service'; +import type { BridgeReaction } from './bridge-executor.service'; + +export interface ConversationTurn { + agentId: string; + agent: Pick; + config: ResolvedAgentConfig; + conversation: ConversationEntity; + subscriber: SubscriberEntity | null; + history: ConversationActivityEntity[]; + message: Message | null; + event: AgentEventEnum; + thread: Thread; + platformThreadId: string; + storedAttachments?: StoredAttachment[]; + action?: AgentAction; + reaction?: BridgeReaction; +} diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/platform-thread.util.ts b/apps/api/src/app/agents/conversation-runtime/runtime/platform-thread.util.ts new file mode 100644 index 00000000000..81e4ddbc172 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/runtime/platform-thread.util.ts @@ -0,0 +1,7 @@ +import type { Thread } from 'chat'; + +export function applyPlatformThreadIdToThread(thread: Thread, platformThreadId: string): void { + // Chat SDK currently gives top-level Slack DMs an empty-root thread id (`slack:D...:`). + // Patch the in-memory handle before posting fallback replies so Slack receives a real thread root. + (thread as unknown as { id: string }).id = platformThreadId; +} diff --git a/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts b/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts new file mode 100644 index 00000000000..4c91dfcf673 --- /dev/null +++ b/apps/api/src/app/agents/conversation-runtime/runtime/runtime-resolver.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import type { AgentEntity } from '@novu/dal'; +import { ManagedRuntime } from '../../managed-runtime/managed.runtime'; +import type { AgentRuntime } from './agent-runtime.port'; +import { BridgeRuntime } from './bridge.runtime'; + +@Injectable() +export class RuntimeResolver { + constructor( + private readonly bridgeRuntime: BridgeRuntime, + private readonly managedRuntime: ManagedRuntime + ) {} + + resolve(agent: Pick | null): AgentRuntime { + if (agent?.runtime === 'managed' && agent.managedRuntime) { + return this.managedRuntime; + } + + return this.bridgeRuntime; + } +} diff --git a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts index d51994ba3fd..d6d27a4345c 100644 --- a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts @@ -2,8 +2,8 @@ import { ConversationActivitySenderTypeEnum, ConversationActivityTypeEnum, Conve import { testServer } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; -import { AgentExecutionParams, BridgeExecutorService } from '../services/bridge-executor.service'; -import { ChatSdkService } from '../services/chat-sdk.service'; +import { OutboundGateway } from '../conversation-runtime/egress/outbound.gateway'; +import { AgentExecutionParams, BridgeExecutorService } from '../conversation-runtime/runtime/bridge-executor.service'; import { AgentTestContext, activityRepository, @@ -29,15 +29,15 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { bridgeCalls.push(params); }); - const chatSdkService = testServer.getService(ChatSdkService); + const outboundGateway = testServer.getService(OutboundGateway); sinon - .stub(chatSdkService, 'postToConversation') + .stub(outboundGateway, 'postToConversation') .resolves({ messageId: 'platform-msg-1', platformThreadId: 'platform-thread-1' }); sinon - .stub(chatSdkService, 'editInConversation') + .stub(outboundGateway, 'editInConversation') .resolves({ messageId: 'platform-msg-1', platformThreadId: 'platform-thread-1' }); - sinon.stub(chatSdkService, 'reactToMessage').resolves(); - sinon.stub(chatSdkService, 'removeReaction').resolves(); + sinon.stub(outboundGateway, 'reactToMessage').resolves(); + sinon.stub(outboundGateway, 'removeReaction').resolves(); }); function postReply(body: Record) { @@ -288,7 +288,7 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { describe('addReactions', () => { it('should call reactToMessage for each addReaction entry', async () => { const conversationId = await seedConversation(ctx); - const chatSdkService = testServer.getService(ChatSdkService); + const outboundGateway = testServer.getService(OutboundGateway); const res = await postReply({ conversationId, @@ -300,13 +300,13 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { }); expect(res.status).to.equal(200); - expect((chatSdkService.reactToMessage as sinon.SinonStub).callCount).to.equal(2); + expect((outboundGateway.reactToMessage as sinon.SinonStub).callCount).to.equal(2); - const firstCall = (chatSdkService.reactToMessage as sinon.SinonStub).getCall(0).args; + const firstCall = (outboundGateway.reactToMessage as sinon.SinonStub).getCall(0).args; expect(firstCall[4]).to.equal('msg-abc'); expect(firstCall[5]).to.equal('thumbs_up'); - const secondCall = (chatSdkService.reactToMessage as sinon.SinonStub).getCall(1).args; + const secondCall = (outboundGateway.reactToMessage as sinon.SinonStub).getCall(1).args; expect(secondCall[4]).to.equal('msg-def'); expect(secondCall[5]).to.equal('check'); }); diff --git a/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts b/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts index 58635317d3a..7106edd6e58 100644 --- a/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-slack-roundtrip.e2e.ts @@ -1,7 +1,7 @@ /** * Agent ↔ Slack outbound contract test. * - * Today's Slack agent e2e tests stub `ChatSdkService.postToConversation` / + * Today's Slack agent e2e tests stub `OutboundGateway.postToConversation` / * `editInConversation` / `reactToMessage` and never let the real * `@chat-adapter/slack` adapter make a HTTP call. This file flips that: * @@ -27,8 +27,8 @@ import { Actions, Button, Card, CardText } from '@novu/framework/express'; import { testServer } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; -import { BridgeExecutorService } from '../services/bridge-executor.service'; -import { ChatSdkService } from '../services/chat-sdk.service'; +import { ChatInstanceRegistry } from '../conversation-runtime/ingress/chat-instance.registry'; +import { BridgeExecutorService } from '../conversation-runtime/runtime/bridge-executor.service'; import { AgentTestContext, activityRepository, @@ -201,10 +201,10 @@ describe('Agent Slack Roundtrip - emulate.dev #novu-v2', () => { // against the freshly-reset emulator. The instance key is // `${agentId}:${integrationIdentifier}` and each test creates a fresh agent // + integration, but clearing here is a belt-and-braces guarantee. - const chatSdkService = testServer.getService(ChatSdkService) as unknown as { + const registry = testServer.getService(ChatInstanceRegistry) as unknown as { instances: { clear: () => void }; }; - chatSdkService.instances.clear(); + registry.instances.clear(); sinon.restore(); }); diff --git a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts index e27e8b1b4b7..23175ee46ee 100644 --- a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts @@ -9,12 +9,12 @@ import { testServer } from '@novu/testing'; import { expect } from 'chai'; import type { EmojiValue } from 'chat'; import sinon from 'sinon'; -import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { AgentConfigResolver } from '../services/agent-config-resolver.service'; -import { AgentInboundHandler, InboundReactionEvent } from '../services/agent-inbound-handler.service'; -import { AgentExecutionParams, BridgeExecutorService } from '../services/bridge-executor.service'; -import { ChatSdkService } from '../services/chat-sdk.service'; +import { AgentConfigResolver } from '../channels/agent-config-resolver.service'; +import { ChatInstanceRegistry } from '../conversation-runtime/ingress/chat-instance.registry'; +import { AgentInboundHandler, InboundReactionEvent } from '../conversation-runtime/ingress/inbound-turn.handler'; +import { AgentExecutionParams, BridgeExecutorService } from '../conversation-runtime/runtime/bridge-executor.service'; +import { AgentEventEnum } from '../shared/enums/agent-event.enum'; +import { AgentPlatformEnum } from '../shared/enums/agent-platform.enum'; import { AgentTestContext, activityRepository, @@ -59,9 +59,9 @@ async function pollFor( } async function clearChatSdkInstances(): Promise { - const chatSdkService = testServer.getService(ChatSdkService); + const registry = testServer.getService(ChatInstanceRegistry); - await chatSdkService.onModuleDestroy(); + await registry.onModuleDestroy(); } function mockEmoji(name: string): EmojiValue { diff --git a/apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts b/apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts index 7a359cbdb57..34e059013a2 100644 --- a/apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts +++ b/apps/api/src/app/agents/e2e/helpers/bridge-executor-test-stub.ts @@ -27,7 +27,11 @@ import { import type { AgentBridgeRequest } from '@novu/framework'; import { HttpHeaderKeysEnum } from '@novu/framework/internal'; import sinon from 'sinon'; -import { AgentExecutionParams, BridgeExecutorService, NoBridgeUrlError } from '../../services/bridge-executor.service'; +import { + AgentExecutionParams, + BridgeExecutorService, + NoBridgeUrlError, +} from '../../conversation-runtime/runtime/bridge-executor.service'; interface BridgeExecutorInternals { resolveBridgeUrl: ( diff --git a/apps/api/src/app/agents/e2e/helpers/slack-emulator.ts b/apps/api/src/app/agents/e2e/helpers/slack-emulator.ts index c6506d0afe8..a59c4a82ae8 100644 --- a/apps/api/src/app/agents/e2e/helpers/slack-emulator.ts +++ b/apps/api/src/app/agents/e2e/helpers/slack-emulator.ts @@ -25,7 +25,7 @@ */ import getPort from 'get-port'; -import { esmImport } from '../../utils/esm-import'; +import { esmImport } from '../../shared/util/esm-import'; interface EmulatorInstance { url: string; @@ -216,11 +216,11 @@ export function resetEmulator(): void { * 1. **`module.exports.WebClient`** — wraps the constructor so any * `new WebClient(token)` call constructed AFTER this patch (the typical * case, since `@chat-adapter/slack` is lazy-imported in - * `chat-sdk.service.ts`) gets `slackApiUrl` injected. + * `chat-instance.registry.ts`) gets `slackApiUrl` injected. * 2. **`WebClient.prototype.apiCall`** — mutates `slackApiUrl` and the * underlying axios `baseURL` on every call. This catches WebClient * instances that were constructed BEFORE the patch (e.g. cached on a - * `ChatSdkService.instances` entry surviving across test files), and is + * `ChatInstanceRegistry.instances` entry surviving across test files), and is * also our safety net if the constructor wrap somehow misses an instance. * * `WebClient` reads `slackApiUrl` once at construction to build axios's diff --git a/apps/api/src/app/agents/e2e/telegram-subscriber-link.e2e.ts b/apps/api/src/app/agents/e2e/telegram-subscriber-link.e2e.ts index e35b5ba8dfe..ba547b5cdb3 100644 --- a/apps/api/src/app/agents/e2e/telegram-subscriber-link.e2e.ts +++ b/apps/api/src/app/agents/e2e/telegram-subscriber-link.e2e.ts @@ -9,11 +9,11 @@ import { ChannelTypeEnum, ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/share import { testServer, UserSession } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; -import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import { AgentConfigResolver } from '../services/agent-config-resolver.service'; -import { AgentInboundHandler } from '../services/agent-inbound-handler.service'; -import { TelegramStartCodeService } from '../services/telegram-start-code.service'; +import { AgentConfigResolver } from '../channels/agent-config-resolver.service'; +import { TelegramStartCodeService } from '../channels/telegram-linking/telegram-start-code.service'; +import { AgentInboundHandler } from '../conversation-runtime/ingress/inbound-turn.handler'; +import { AgentEventEnum } from '../shared/enums/agent-event.enum'; +import { AgentPlatformEnum } from '../shared/enums/agent-platform.enum'; const integrationRepository = new IntegrationRepository(); const agentIntegrationRepository = new AgentIntegrationRepository(); diff --git a/apps/api/src/app/agents/services/agent-email-action-token.service.ts b/apps/api/src/app/agents/email/agent-email-action-token.service.ts similarity index 100% rename from apps/api/src/app/agents/services/agent-email-action-token.service.ts rename to apps/api/src/app/agents/email/agent-email-action-token.service.ts diff --git a/apps/api/src/app/agents/agent-email-actions.controller.ts b/apps/api/src/app/agents/email/agent-email-actions.controller.ts similarity index 98% rename from apps/api/src/app/agents/agent-email-actions.controller.ts rename to apps/api/src/app/agents/email/agent-email-actions.controller.ts index 9b8b55eb153..e6c4d9c45fb 100644 --- a/apps/api/src/app/agents/agent-email-actions.controller.ts +++ b/apps/api/src/app/agents/email/agent-email-actions.controller.ts @@ -2,6 +2,8 @@ import { Body, Controller, Get, HttpStatus, Post, Query, Res } from '@nestjs/com import { ApiExcludeController } from '@nestjs/swagger'; import { PinoLogger } from '@novu/application-generic'; import { Response } from 'express'; +import { AgentActionPreDispatchError, InboundDispatcher } from '../conversation-runtime/ingress/inbound.dispatcher'; +import { captureAgentException, captureAgentWarning } from '../shared/errors/capture-agent-sentry'; import { AgentEmailActionCacheUnavailableError, AgentEmailActionClaims, @@ -9,9 +11,7 @@ import { AgentEmailActionTokenService, ConsumedActionToken, PeekedActionToken, -} from './services/agent-email-action-token.service'; -import { captureAgentException, captureAgentWarning } from './utils/capture-agent-sentry'; -import { AgentActionPreDispatchError, ChatSdkService } from './services/chat-sdk.service'; +} from './agent-email-action-token.service'; const EXECUTE_PATH = '/v1/agents/email/actions/execute'; @@ -41,7 +41,7 @@ const EXECUTE_PATH = '/v1/agents/email/actions/execute'; export class AgentEmailActionsController { constructor( private readonly tokenService: AgentEmailActionTokenService, - private readonly chatSdkService: ChatSdkService, + private readonly inboundDispatcher: InboundDispatcher, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -137,7 +137,7 @@ export class AgentEmailActionsController { const { claims } = consumed; try { - await this.chatSdkService.processEmailAction(claims); + await this.inboundDispatcher.processEmailAction(claims); } catch (err) { this.logger.error(err, `Failed to process agent email action ${claims.actionId} for agent ${claims.agentId}`); captureAgentException(err, { diff --git a/apps/api/src/app/agents/email/agent-email-sender.service.ts b/apps/api/src/app/agents/email/agent-email-sender.service.ts new file mode 100644 index 00000000000..ab95f9632a0 --- /dev/null +++ b/apps/api/src/app/agents/email/agent-email-sender.service.ts @@ -0,0 +1,340 @@ +import { randomUUID } from 'node:crypto'; +import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common'; +import { + areNovuEmailCredentialsSet, + buildAgentSharedInbox, + CalculateLimitNovuIntegration, + decryptCredentials, + isAgentSharedInboxEnabled, + MailFactory, + PinoLogger, +} from '@novu/application-generic'; +import { IntegrationEntity, IntegrationRepository, MessageRepository } from '@novu/dal'; +import { ChannelTypeEnum, EmailProviderIdEnum, type IEmailOptions } from '@novu/shared'; +import type { ResolvedAgentConfig } from '../channels/agent-config-resolver.service'; +import { captureAgentWarning } from '../shared/errors/capture-agent-sentry'; + +const EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS = new Set([ + EmailProviderIdEnum.CustomSMTP, + EmailProviderIdEnum.Outlook365, + EmailProviderIdEnum.SendGrid, + EmailProviderIdEnum.SES, +]); + +function getErrorResponseBody(err: unknown): unknown { + if (!err || typeof err !== 'object') { + return undefined; + } + + return (err as { response?: { body?: unknown } }).response?.body; +} + +function getDeliveryErrorDetail(body: unknown): string | undefined { + if (!body || typeof body !== 'object') { + return undefined; + } + + const responseBody = body as { errors?: Array<{ message?: unknown }>; message?: unknown }; + const firstErrorMessage = responseBody.errors?.[0]?.message; + if (typeof firstErrorMessage === 'string') { + return firstErrorMessage; + } + + return typeof responseBody.message === 'string' ? responseBody.message : undefined; +} + +function toDeliveryError(err: unknown): never { + const base = err instanceof Error ? err.message : String(err); + const detail = getDeliveryErrorDetail(getErrorResponseBody(err)); + + throw new BadGatewayException({ + error: 'delivery_failed', + message: detail ? `${base}: ${detail}` : base, + }); +} + +/** Ensure a Message-ID value is wrapped in RFC 5322 angle brackets. */ +function wrapMsgId(id: string): string { + const trimmed = id.trim(); + + return trimmed.startsWith('<') && trimmed.endsWith('>') ? trimmed : `<${trimmed}>`; +} + +export function resolveAgentEmailSenderName(config: ResolvedAgentConfig): string { + return config.credentials.senderName?.trim() || config.agentName; +} + +@Injectable() +export class AgentEmailSender { + constructor( + private readonly logger: PinoLogger, + private readonly integrationRepository: IntegrationRepository, + private readonly calculateLimitNovuIntegration: CalculateLimitNovuIntegration, + private readonly messageRepository: MessageRepository + ) { + this.logger.setContext(this.constructor.name); + } + + buildSendEmailCallback( + config: ResolvedAgentConfig, + outboundIntegrationId: string | undefined + ): (params: { + from: string; + to: string; + subject: string; + html: string; + text?: string; + alternatives?: Array<{ + contentType: string; + content: string | Buffer; + }>; + inReplyTo?: string; + references?: string; + messageId?: string; + }) => Promise<{ messageId?: string }> { + return async (params) => { + if (!outboundIntegrationId) { + throw new BadRequestException( + 'Email agent integration is missing outboundIntegrationId. Reconfigure the agent email setup.' + ); + } + + const integration = await this.integrationRepository.findOne({ + _id: outboundIntegrationId, + _environmentId: config.environmentId, + _organizationId: config.organizationId, + channel: ChannelTypeEnum.EMAIL, + }); + + if (!integration) { + throw new BadRequestException( + `Outbound email integration ${outboundIntegrationId} not found or does not belong to this environment` + ); + } + + if (integration.providerId === EmailProviderIdEnum.NovuAgent) { + throw new BadRequestException( + `Integration ${outboundIntegrationId} is the inbound NovuAgent provider and cannot be used as an outbound sender` + ); + } + + if (!integration.active) { + throw new BadRequestException( + `Outbound email integration ${outboundIntegrationId} (${integration.providerId}) is inactive` + ); + } + + if (integration.providerId === EmailProviderIdEnum.Novu) { + return this.sendViaNovuDemoProvider(config, params, integration); + } + + const hasUnsupportedAlternatives = + params.alternatives?.length && !EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS.has(integration.providerId); + if (hasUnsupportedAlternatives) { + if (!params.messageId) { + this.logger.warn( + { + providerId: integration.providerId, + outboundIntegrationId, + }, + 'Skipping email with custom MIME alternatives because the outbound provider is unsupported and no messageId was supplied' + ); + + return { messageId: undefined }; + } + + this.logger.warn( + { + providerId: integration.providerId, + outboundIntegrationId, + }, + 'Skipping email reaction because the outbound provider does not support custom MIME alternatives' + ); + + return { messageId: params.messageId }; + } + + const decrypted = decryptCredentials(integration.credentials); + + const agentInboundAddress = this.resolveAgentInboundAddress(config, params.from); + const overrideFrom = config.credentials.useFromAddressOverride + ? config.credentials.fromAddressOverride?.trim() || undefined + : undefined; + const outboundFrom = (decrypted.from as string | undefined)?.trim() || undefined; + const effectiveFrom = overrideFrom || agentInboundAddress || outboundFrom; + const replyToHeader = effectiveFrom !== agentInboundAddress ? agentInboundAddress : undefined; + const senderName = resolveAgentEmailSenderName(config); + + const mailFactory = new MailFactory(); + const handler = mailFactory.getHandler({ ...integration, credentials: decrypted }, effectiveFrom); + + const mailOptions: IEmailOptions = { + to: [params.to], + subject: params.subject, + html: params.html, + text: params.text, + alternatives: params.alternatives, + from: effectiveFrom, + ...(replyToHeader ? { replyTo: replyToHeader } : {}), + senderName, + headers: { + ...(params.messageId ? { 'Message-ID': wrapMsgId(params.messageId) } : {}), + ...(params.inReplyTo ? { 'In-Reply-To': wrapMsgId(params.inReplyTo) } : {}), + ...(params.references + ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } + : {}), + }, + }; + + const result = await handler.send(mailOptions).catch(toDeliveryError); + + return { messageId: result?.id || params.messageId || '' }; + }; + } + + /** + * Resolve the canonical inbound address used for Reply-To. Preference order: + * + * 1. The synthetic shared inbox `{slug}-{inboxRoutingKey}@` + * 2. The fallback supplied by the chat-adapter-email SDK + */ + resolveAgentInboundAddress(config: ResolvedAgentConfig, fallback: string): string { + const slug = config.credentials.emailSlugPrefix; + const inboxRoutingKey = config.credentials.inboxRoutingKey; + const sharedDisabled = Boolean(config.credentials.sharedInboxDisabled); + if (isAgentSharedInboxEnabled() && slug && inboxRoutingKey && !sharedDisabled) { + try { + return buildAgentSharedInbox(slug, inboxRoutingKey); + } catch (err) { + this.logger.warn({ err, agentId: config.agentId }, 'Falling back to params.from - shared inbox build failed'); + captureAgentWarning(err, { + component: 'chat-sdk', + operation: 'resolve-agent-inbound-address', + agentId: config.agentId, + }); + } + } + + return fallback; + } + + /** + * Outbound demo path: the agent is wired to the bundled Novu Email demo + * provider row. Quota-gated by the same per-environment 300/month cap as + * workflow notification emails. + */ + private async sendViaNovuDemoProvider( + config: ResolvedAgentConfig, + params: { + from: string; + to: string; + subject: string; + html: string; + text?: string; + alternatives?: Array<{ contentType: string; content: string | Buffer }>; + inReplyTo?: string; + references?: string; + messageId?: string; + }, + integration: IntegrationEntity + ): Promise<{ messageId?: string }> { + if (!isAgentSharedInboxEnabled() || !config.credentials.emailSlugPrefix || !config.credentials.inboxRoutingKey) { + throw new BadRequestException( + 'Email agent integration requires either a shared agent inbox or a custom outbound email provider. ' + + 'Configure one in the agent email setup.' + ); + } + + if (config.credentials.sharedInboxDisabled) { + throw new BadRequestException( + 'The Novu demo sender requires the shared inbox to be enabled. ' + + 'Re-enable it or attach an outbound email provider.' + ); + } + + const limit = await this.calculateLimitNovuIntegration.execute({ + channelType: ChannelTypeEnum.EMAIL, + environmentId: config.environmentId, + organizationId: config.organizationId, + }); + if (limit && limit.count >= limit.limit) { + throw new BadRequestException( + `Novu demo email quota exhausted for this environment (${limit.count}/${limit.limit} this month). Attach an outbound email provider (e.g. SendGrid) to remove this cap.` + ); + } + + if (!areNovuEmailCredentialsSet()) { + throw new BadRequestException( + 'Novu demo email is not configured on this deployment. Attach an outbound email provider to send replies.' + ); + } + + const from = buildAgentSharedInbox(config.credentials.emailSlugPrefix, config.credentials.inboxRoutingKey); + const senderName = resolveAgentEmailSenderName(config); + + const demoIntegration: IntegrationEntity = { + ...integration, + credentials: { + apiKey: process.env.NOVU_EMAIL_INTEGRATION_API_KEY, + from, + senderName, + ipPoolName: 'Demo', + }, + }; + + const mailFactory = new MailFactory(); + const handler = mailFactory.getHandler(demoIntegration, from); + + const mailOptions: IEmailOptions = { + to: [params.to], + subject: params.subject, + html: params.html, + text: params.text, + alternatives: params.alternatives, + from, + senderName, + headers: { + ...(params.messageId ? { 'Message-ID': wrapMsgId(params.messageId) } : {}), + ...(params.inReplyTo ? { 'In-Reply-To': wrapMsgId(params.inReplyTo) } : {}), + ...(params.references + ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } + : {}), + }, + }; + + const result = await handler.send(mailOptions).catch(toDeliveryError); + + const messageIdForReturn = result?.id || params.messageId || ''; + + try { + await this.messageRepository.create({ + _environmentId: config.environmentId, + _organizationId: config.organizationId, + channel: ChannelTypeEnum.EMAIL, + providerId: EmailProviderIdEnum.Novu, + email: params.to, + subject: params.subject, + transactionId: messageIdForReturn || randomUUID(), + payload: { + agentId: config.agentId, + html: params.html, + text: params.text, + }, + tags: ['agent-demo-reply'], + }); + } catch (err) { + this.logger.warn( + { err, environmentId: config.environmentId, agentId: config.agentId }, + 'Failed to persist Novu demo email message for quota accounting' + ); + captureAgentWarning(err, { + component: 'chat-sdk', + operation: 'persist-demo-email-quota', + agentId: config.agentId, + extra: { environmentId: config.environmentId }, + }); + } + + return { messageId: messageIdForReturn }; + } +} diff --git a/apps/api/src/app/agents/email/index.ts b/apps/api/src/app/agents/email/index.ts new file mode 100644 index 00000000000..cc25fc82ea6 --- /dev/null +++ b/apps/api/src/app/agents/email/index.ts @@ -0,0 +1,3 @@ +export { AgentEmailActionTokenService } from './agent-email-action-token.service'; +export { AgentEmailActionsController } from './agent-email-actions.controller'; +export { AgentEmailSender } from './agent-email-sender.service'; diff --git a/apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts b/apps/api/src/app/agents/email/novu-email/cleanup-novu-email/cleanup-novu-email.service.ts similarity index 97% rename from apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts rename to apps/api/src/app/agents/email/novu-email/cleanup-novu-email/cleanup-novu-email.service.ts index 5227955666f..f2509e8e465 100644 --- a/apps/api/src/app/agents/usecases/cleanup-novu-email/cleanup-novu-email.usecase.ts +++ b/apps/api/src/app/agents/email/novu-email/cleanup-novu-email/cleanup-novu-email.service.ts @@ -4,10 +4,10 @@ import { AgentIntegrationRepository, DomainRouteRepository, IntegrationRepositor import { EmailProviderIdEnum } from '@novu/shared'; import { ClientSession } from 'mongoose'; -const LOG_CONTEXT = 'CleanupNovuEmail'; +const LOG_CONTEXT = 'NovuEmailCleanupService'; @Injectable() -export class CleanupNovuEmail { +export class NovuEmailCleanupService { constructor( private readonly agentIntegrationRepository: AgentIntegrationRepository, private readonly integrationRepository: IntegrationRepository, diff --git a/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts b/apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service.ts similarity index 98% rename from apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts rename to apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service.ts index d064baca739..f9535de9e8d 100644 --- a/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.usecase.ts +++ b/apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service.ts @@ -34,8 +34,8 @@ import { import { ClientSession } from 'mongoose'; import shortid from 'shortid'; -import type { AgentIntegrationResponseDto } from '../../dtos'; -import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationResponseDto } from '../../../shared/dtos'; +import { toAgentIntegrationResponse } from '../../../shared/mappers/agent-response.mapper'; /** * Max collision-retry attempts when minting the per-agent inbox routing key. @@ -58,7 +58,7 @@ function sanitizeSlug(input: string): string { } @Injectable() -export class FindOrCreateNovuEmail { +export class NovuEmailProvisioningService { constructor( private readonly integrationRepository: IntegrationRepository, private readonly agentIntegrationRepository: AgentIntegrationRepository, diff --git a/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.spec.ts b/apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.spec.ts similarity index 97% rename from apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.spec.ts rename to apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.spec.ts index f141fad8dd2..6b06580e2dd 100644 --- a/apps/api/src/app/agents/usecases/find-or-create-novu-email/find-or-create-novu-email.spec.ts +++ b/apps/api/src/app/agents/email/novu-email/find-or-create-novu-email/find-or-create-novu-email.spec.ts @@ -4,7 +4,7 @@ import { ApiServiceLevelEnum, ChannelTypeEnum, EmailProviderIdEnum, NOVU_PROVIDE import { expect } from 'chai'; import { restore, stub } from 'sinon'; -import { FindOrCreateNovuEmail } from './find-or-create-novu-email.usecase'; +import { NovuEmailProvisioningService } from './find-or-create-novu-email.service'; const ENV_ID = 'env-id'; const ORG_ID = 'org-id'; @@ -24,7 +24,7 @@ function makeAgent(overrides: Partial = {}): AgentEntity { } as AgentEntity; } -describe('FindOrCreateNovuEmail usecase', () => { +describe('NovuEmailProvisioningService', () => { let integrationRepo: { find: sinon.SinonStub; findOne: sinon.SinonStub; @@ -48,7 +48,7 @@ describe('FindOrCreateNovuEmail usecase', () => { let savedSharedDomain: string | undefined; function buildUsecase() { - return new FindOrCreateNovuEmail( + return new NovuEmailProvisioningService( integrationRepo as any, agentIntegrationRepo as any, organizationRepo as any, diff --git a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts b/apps/api/src/app/agents/email/send-agent-test-email/send-agent-test-email.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.command.ts rename to apps/api/src/app/agents/email/send-agent-test-email/send-agent-test-email.command.ts diff --git a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts b/apps/api/src/app/agents/email/send-agent-test-email/send-agent-test-email.usecase.ts similarity index 97% rename from apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts rename to apps/api/src/app/agents/email/send-agent-test-email/send-agent-test-email.usecase.ts index 84d53ca122f..7b90db27d7b 100644 --- a/apps/api/src/app/agents/usecases/send-agent-test-email/send-agent-test-email.usecase.ts +++ b/apps/api/src/app/agents/email/send-agent-test-email/send-agent-test-email.usecase.ts @@ -10,7 +10,7 @@ import { import { AgentIntegrationRepository, AgentRepository, IntegrationEntity, IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum, EmailProviderIdEnum, IEmailOptions } from '@novu/shared'; -import { trackAgentTestEmailSent } from '../../agent-analytics'; +import { trackAgentTestEmailSent } from '../../shared/analytics/agent-analytics'; import { SendAgentTestEmailCommand } from './send-agent-test-email.command'; function escapeHtml(text: string): string { @@ -168,7 +168,7 @@ export class SendAgentTestEmail { // The Novu demo email integration is provisioned alongside each org but // ships with empty stored credentials — the real SendGrid API key lives in // the deployment's `NOVU_EMAIL_INTEGRATION_API_KEY` env var. Mirror the - // runtime resolution in chat-sdk.service.ts so test emails go through the + // runtime resolution in chat-instance.registry.ts so test emails go through the // same demo plumbing as actual agent replies. if (configured.providerId === EmailProviderIdEnum.Novu) { return { diff --git a/apps/api/src/app/agents/index.ts b/apps/api/src/app/agents/index.ts new file mode 100644 index 00000000000..feeec093cdb --- /dev/null +++ b/apps/api/src/app/agents/index.ts @@ -0,0 +1 @@ +export { AgentsModule } from './agents.module'; diff --git a/apps/api/src/app/agents/usecases/calculate-demo-claude-quota/calculate-demo-claude-quota.usecase.spec.ts b/apps/api/src/app/agents/managed-runtime/calculate-demo-claude-quota.spec.ts similarity index 100% rename from apps/api/src/app/agents/usecases/calculate-demo-claude-quota/calculate-demo-claude-quota.usecase.spec.ts rename to apps/api/src/app/agents/managed-runtime/calculate-demo-claude-quota.spec.ts diff --git a/apps/api/src/app/agents/services/demo-claude-quota-policy.service.ts b/apps/api/src/app/agents/managed-runtime/demo-claude-quota-policy.service.ts similarity index 100% rename from apps/api/src/app/agents/services/demo-claude-quota-policy.service.ts rename to apps/api/src/app/agents/managed-runtime/demo-claude-quota-policy.service.ts diff --git a/apps/api/src/app/agents/managed-runtime/index.ts b/apps/api/src/app/agents/managed-runtime/index.ts new file mode 100644 index 00000000000..cf739fc9159 --- /dev/null +++ b/apps/api/src/app/agents/managed-runtime/index.ts @@ -0,0 +1,4 @@ +export { DemoClaudeQuotaPolicy } from './demo-claude-quota-policy.service'; +export { ManagedAgentService } from './managed-agent.service'; +export { ManagedAgentEventHandler } from './managed-agent-event-handler.service'; +export { ManagedAgentProviderFactory } from './managed-agent-provider-factory.service'; diff --git a/apps/api/src/app/agents/services/managed-agent-event-handler.ts b/apps/api/src/app/agents/managed-runtime/managed-agent-event-handler.service.ts similarity index 91% rename from apps/api/src/app/agents/services/managed-agent-event-handler.ts rename to apps/api/src/app/agents/managed-runtime/managed-agent-event-handler.service.ts index 329d826af67..481bebb1e09 100644 --- a/apps/api/src/app/agents/services/managed-agent-event-handler.ts +++ b/apps/api/src/app/agents/managed-runtime/managed-agent-event-handler.service.ts @@ -9,14 +9,14 @@ import { type StreamCallbacks, type Response as ThalamusResponse, } from '@novu/thalamus'; -import { HandleAgentReplyCommand } from '../usecases/handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-reply.usecase'; -import { HandlePlanProgressCommand } from '../usecases/handle-plan-progress/handle-plan-progress.command'; -import { HandlePlanProgress } from '../usecases/handle-plan-progress/handle-plan-progress.usecase'; -import { HandlePendingToolApprovalsCommand } from '../usecases/tool-approval/handle-pending-tool-approvals.command'; -import { HandlePendingToolApprovals } from '../usecases/tool-approval/handle-pending-tool-approvals.usecase'; -import { captureAgentException } from '../utils/capture-agent-sentry'; +import { HandleAgentReplyCommand } from '../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { HandlePlanProgressCommand } from '../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command'; +import { HandlePlanProgress } from '../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase'; +import { captureAgentException } from '../shared/errors/capture-agent-sentry'; import { DemoClaudeQuotaPolicy } from './demo-claude-quota-policy.service'; +import { HandlePendingToolApprovalsCommand } from './tool-approval/handle-pending-tool-approvals.command'; +import { HandlePendingToolApprovals } from './tool-approval/handle-pending-tool-approvals.usecase'; interface BaseCommandFields { userId: string; diff --git a/apps/api/src/app/agents/services/managed-agent-provider-factory.ts b/apps/api/src/app/agents/managed-runtime/managed-agent-provider-factory.service.ts similarity index 100% rename from apps/api/src/app/agents/services/managed-agent-provider-factory.ts rename to apps/api/src/app/agents/managed-runtime/managed-agent-provider-factory.service.ts index c254b182baf..c5c2edea5ee 100644 --- a/apps/api/src/app/agents/services/managed-agent-provider-factory.ts +++ b/apps/api/src/app/agents/managed-runtime/managed-agent-provider-factory.service.ts @@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common'; import { type IAgentRuntimeProvider, PinoLogger, - resolveAgentRuntime, type ResolvedAwsAnthropicCredentials, + resolveAgentRuntime, toThalamusAwsAnthropicCredentials, } from '@novu/application-generic'; import { type AgentEntity, AgentRepository, IntegrationRepository } from '@novu/dal'; diff --git a/apps/api/src/app/agents/services/managed-agent.service.ts b/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts similarity index 97% rename from apps/api/src/app/agents/services/managed-agent.service.ts rename to apps/api/src/app/agents/managed-runtime/managed-agent.service.ts index ba237391fe2..8c010857311 100644 --- a/apps/api/src/app/agents/services/managed-agent.service.ts +++ b/apps/api/src/app/agents/managed-runtime/managed-agent.service.ts @@ -14,12 +14,12 @@ import { import { type Message, MessageRole } from '@novu/thalamus'; import { createWebhookHandler, type WebhookHandler } from '@novu/thalamus/webhook'; import type { Request, Response } from 'express'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import type { ResolvedAgentConfig } from './agent-config-resolver.service'; +import type { ResolvedAgentConfig } from '../channels/agent-config-resolver.service'; +import { McpConnectionVaultService } from '../mcp/connections/mcp-connection-vault.service'; +import { AgentPlatformEnum } from '../shared/enums/agent-platform.enum'; import { DemoClaudeQuotaPolicy } from './demo-claude-quota-policy.service'; -import { ManagedAgentEventHandler } from './managed-agent-event-handler'; -import { ManagedAgentProviderFactory } from './managed-agent-provider-factory'; -import { McpConnectionVaultService } from './mcp-connection-vault.service'; +import { ManagedAgentEventHandler } from './managed-agent-event-handler.service'; +import { ManagedAgentProviderFactory } from './managed-agent-provider-factory.service'; export interface ManagedAgentContext { config: ResolvedAgentConfig; diff --git a/apps/api/src/app/agents/managed-runtime/managed-runtime.controller.ts b/apps/api/src/app/agents/managed-runtime/managed-runtime.controller.ts new file mode 100644 index 00000000000..026a620b795 --- /dev/null +++ b/apps/api/src/app/agents/managed-runtime/managed-runtime.controller.ts @@ -0,0 +1,15 @@ +import { Controller, Post, Req, Res } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Request, Response } from 'express'; +import { ManagedAgentService } from './managed-agent.service'; + +@Controller('/agents') +@ApiExcludeController() +export class ManagedRuntimeController { + constructor(private managedAgentService: ManagedAgentService) {} + + @Post('/events') + async handleThalamusEvent(@Req() req: Request, @Res() res: Response) { + await this.managedAgentService.handleWebhook(req, res); + } +} diff --git a/apps/api/src/app/agents/managed-runtime/managed.runtime.ts b/apps/api/src/app/agents/managed-runtime/managed.runtime.ts new file mode 100644 index 00000000000..3737fd83fa7 --- /dev/null +++ b/apps/api/src/app/agents/managed-runtime/managed.runtime.ts @@ -0,0 +1,129 @@ +import { Injectable } from '@nestjs/common'; +import { DEMO_QUOTA_EXHAUSTED_REPLY, DemoQuotaExhaustedError, PinoLogger } from '@novu/application-generic'; +import { AgentConversationService } from '../conversation-runtime/conversation/agent-conversation.service'; +import { OutboundGateway } from '../conversation-runtime/egress/outbound.gateway'; +import type { AgentRuntime } from '../conversation-runtime/runtime/agent-runtime.port'; +import type { ConversationTurn } from '../conversation-runtime/runtime/conversation-turn'; +import { applyPlatformThreadIdToThread } from '../conversation-runtime/runtime/platform-thread.util'; +import { AgentEventEnum } from '../shared/enums/agent-event.enum'; +import { ManagedAgentService } from './managed-agent.service'; +import { HandleManagedAgentSetupInbound } from './setup/handle-managed-agent-setup-inbound.usecase'; +import { ManagedAgentSetupInboundCommand } from './setup/managed-agent-setup-inbound.command'; +import { parseToolApprovalActionId } from './tool-approval/approval-card.builder'; +import { ConfirmToolApprovalCommand } from './tool-approval/confirm-tool-approval.command'; +import { ConfirmToolApproval } from './tool-approval/confirm-tool-approval.usecase'; + +@Injectable() +export class ManagedRuntime implements AgentRuntime { + constructor( + private readonly managedAgentService: ManagedAgentService, + private readonly handleManagedAgentSetupInbound: HandleManagedAgentSetupInbound, + private readonly confirmToolApproval: ConfirmToolApproval, + private readonly outboundGateway: OutboundGateway, + private readonly conversationService: AgentConversationService, + private readonly logger: PinoLogger + ) { + this.logger.setContext(this.constructor.name); + } + + async dispatch(turn: ConversationTurn): Promise { + if (turn.event === AgentEventEnum.ON_ACTION) { + await this.handleAction(turn); + + return; + } + + // Managed agents otherwise only act on inbound messages (reactions are bridge-only today). + if (turn.event !== AgentEventEnum.ON_MESSAGE) { + return; + } + + if (turn.subscriber && turn.message?.id) { + const parked = await this.handleManagedAgentSetupInbound.execute( + ManagedAgentSetupInboundCommand.create({ + userId: 'system', + environmentId: turn.config.environmentId, + organizationId: turn.config.organizationId, + conversationId: turn.conversation._id, + agentId: turn.agent._id, + subscriberId: turn.subscriber.subscriberId, + agentIdentifier: turn.config.agentIdentifier, + integrationIdentifier: turn.config.integrationIdentifier, + platformMessageId: turn.message.id, + }) + ); + + if (parked) { + return; + } + } + + try { + await this.managedAgentService.dispatch( + { + config: turn.config, + conversation: turn.conversation, + subscriber: turn.subscriber, + userMessageText: turn.message?.text ?? '', + }, + turn.agent + ); + } catch (err) { + if (err instanceof DemoQuotaExhaustedError) { + await this.replyDemoQuotaExhausted(turn); + + return; + } + + throw err; + } + } + + /** + * Card clicks on a managed agent are Novu-internal only: MCP Approve/Deny + * (mcp-approval:*) is confirmed here; any other id is a no-op (managed agents + * have no bridge onAction to forward to, and link buttons are handled in ingress). + */ + private async handleAction(turn: ConversationTurn): Promise { + const toolApproval = parseToolApprovalActionId(turn.action?.id); + + if (!toolApproval) { + return; + } + + await this.confirmToolApproval.execute( + ConfirmToolApprovalCommand.create({ + userId: 'system', + environmentId: turn.config.environmentId, + organizationId: turn.config.organizationId, + conversationId: turn.conversation._id, + agentIdentifier: turn.config.agentIdentifier, + integrationIdentifier: turn.config.integrationIdentifier, + agentId: turn.agentId, + subscriberId: turn.subscriber?.subscriberId ?? undefined, + platform: turn.config.platform, + parsed: toolApproval, + sourceMessageId: turn.action?.sourceMessageId, + actionValue: turn.action?.value, + }) + ); + } + + private async replyDemoQuotaExhausted(turn: ConversationTurn): Promise { + applyPlatformThreadIdToThread(turn.thread, turn.platformThreadId); + await this.outboundGateway.replyOnThread( + turn.thread, + { markdown: DEMO_QUOTA_EXHAUSTED_REPLY }, + { + persist: { + conversationId: turn.conversation._id, + channel: this.conversationService.getPrimaryChannel(turn.conversation), + agentIdentifier: turn.config.agentIdentifier, + content: DEMO_QUOTA_EXHAUSTED_REPLY, + environmentId: turn.config.environmentId, + organizationId: turn.config.organizationId, + }, + } + ); + } +} diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/complete-managed-agent-setup.usecase.ts b/apps/api/src/app/agents/managed-runtime/setup/complete-managed-agent-setup.usecase.ts similarity index 92% rename from apps/api/src/app/agents/usecases/managed-agent-setup/complete-managed-agent-setup.usecase.ts rename to apps/api/src/app/agents/managed-runtime/setup/complete-managed-agent-setup.usecase.ts index 9913007bb4a..38fa6eb77bc 100644 --- a/apps/api/src/app/agents/usecases/managed-agent-setup/complete-managed-agent-setup.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/setup/complete-managed-agent-setup.usecase.ts @@ -12,14 +12,13 @@ import { SubscriberEntity, SubscriberRepository, } from '@novu/dal'; - -import { PLATFORMS_WITH_TYPING_INDICATOR } from '../../dtos/agent-platform.enum'; -import { AgentConfigResolver, type ResolvedAgentConfig } from '../../services/agent-config-resolver.service'; -import { ChatSdkService } from '../../services/chat-sdk.service'; -import { ManagedAgentService } from '../../services/managed-agent.service'; -import { GenerateMcpOAuthUrl } from '../generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; -import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; +import { AgentConfigResolver, type ResolvedAgentConfig } from '../../channels/agent-config-resolver.service'; +import { OutboundGateway } from '../../conversation-runtime/egress/outbound.gateway'; +import { HandleAgentReplyCommand } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { GenerateMcpOAuthUrl } from '../../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; +import { PLATFORMS_WITH_TYPING_INDICATOR } from '../../shared/enums/agent-platform.enum'; +import { ManagedAgentService } from '../managed-agent.service'; import { listOAuthMcps } from './list-oauth-mcps.helper'; import { ManagedAgentSetupCompleteCommand } from './managed-agent-setup-complete.command'; import { isOAuthMcpPending, type OAuthMcp } from './oauth-mcp.types'; @@ -42,7 +41,7 @@ export class CompleteManagedAgentSetup { private readonly managedAgentService: ManagedAgentService, private readonly generateMcpOAuthUrl: GenerateMcpOAuthUrl, private readonly handleAgentReply: HandleAgentReply, - private readonly chatSdkService: ChatSdkService, + private readonly outboundGateway: OutboundGateway, private readonly logger: PinoLogger ) { this.logger.setContext(this.constructor.name); @@ -342,7 +341,7 @@ export class CompleteManagedAgentSetup { } try { - await this.chatSdkService.startTypingInConversation(agent._id, config.integrationIdentifier, platformThreadId); + await this.outboundGateway.startTypingInConversation(agent._id, config.integrationIdentifier, platformThreadId); } catch (err) { this.logger.warn( err, diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/handle-managed-agent-setup-inbound.usecase.ts b/apps/api/src/app/agents/managed-runtime/setup/handle-managed-agent-setup-inbound.usecase.ts similarity index 95% rename from apps/api/src/app/agents/usecases/managed-agent-setup/handle-managed-agent-setup-inbound.usecase.ts rename to apps/api/src/app/agents/managed-runtime/setup/handle-managed-agent-setup-inbound.usecase.ts index dba00ab188b..c29a3378197 100644 --- a/apps/api/src/app/agents/usecases/managed-agent-setup/handle-managed-agent-setup-inbound.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/setup/handle-managed-agent-setup-inbound.usecase.ts @@ -8,10 +8,9 @@ import { PendingManagedAgentSetup, SubscriberRepository, } from '@novu/dal'; - -import { GenerateMcpOAuthUrl } from '../generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; -import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; +import { HandleAgentReplyCommand } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { GenerateMcpOAuthUrl } from '../../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; import { listOAuthMcps } from './list-oauth-mcps.helper'; import { ManagedAgentSetupInboundCommand } from './managed-agent-setup-inbound.command'; import { isOAuthMcpPending, type OAuthMcp } from './oauth-mcp.types'; diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/list-oauth-mcps.helper.ts b/apps/api/src/app/agents/managed-runtime/setup/list-oauth-mcps.helper.ts similarity index 100% rename from apps/api/src/app/agents/usecases/managed-agent-setup/list-oauth-mcps.helper.ts rename to apps/api/src/app/agents/managed-runtime/setup/list-oauth-mcps.helper.ts diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/managed-agent-setup-complete.command.ts b/apps/api/src/app/agents/managed-runtime/setup/managed-agent-setup-complete.command.ts similarity index 62% rename from apps/api/src/app/agents/usecases/managed-agent-setup/managed-agent-setup-complete.command.ts rename to apps/api/src/app/agents/managed-runtime/setup/managed-agent-setup-complete.command.ts index b66fb1e9f12..d1b330fd5d6 100644 --- a/apps/api/src/app/agents/usecases/managed-agent-setup/managed-agent-setup-complete.command.ts +++ b/apps/api/src/app/agents/managed-runtime/setup/managed-agent-setup-complete.command.ts @@ -1,6 +1,6 @@ import { BaseCommand } from '@novu/application-generic'; -import type { McpOAuthState } from '../generate-mcp-oauth-url/mcp-oauth-state'; +import type { McpOAuthState } from '../../mcp/oauth/generate-mcp-oauth-url/mcp-oauth-state'; export class ManagedAgentSetupCompleteCommand extends BaseCommand { stateData: McpOAuthState; diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/managed-agent-setup-inbound.command.ts b/apps/api/src/app/agents/managed-runtime/setup/managed-agent-setup-inbound.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/managed-agent-setup/managed-agent-setup-inbound.command.ts rename to apps/api/src/app/agents/managed-runtime/setup/managed-agent-setup-inbound.command.ts diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/oauth-mcp.types.ts b/apps/api/src/app/agents/managed-runtime/setup/oauth-mcp.types.ts similarity index 100% rename from apps/api/src/app/agents/usecases/managed-agent-setup/oauth-mcp.types.ts rename to apps/api/src/app/agents/managed-runtime/setup/oauth-mcp.types.ts diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.builder.ts b/apps/api/src/app/agents/managed-runtime/setup/setup-card.builder.ts similarity index 88% rename from apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.builder.ts rename to apps/api/src/app/agents/managed-runtime/setup/setup-card.builder.ts index a022a900edf..a6a664fd9af 100644 --- a/apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.builder.ts +++ b/apps/api/src/app/agents/managed-runtime/setup/setup-card.builder.ts @@ -1,8 +1,8 @@ import { PinoLogger } from '@novu/application-generic'; import { McpConnectionStatusEnum } from '@novu/shared'; -import { GenerateMcpOAuthUrlCommand } from '../generate-mcp-oauth-url/generate-mcp-oauth-url.command'; -import { GenerateMcpOAuthUrl } from '../generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; +import { GenerateMcpOAuthUrlCommand } from '../../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.command'; +import { GenerateMcpOAuthUrl } from '../../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; import type { OAuthMcp } from './oauth-mcp.types'; import { buildSetupCard, type SetupCardRow } from './setup-card.helpers'; diff --git a/apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.helpers.ts b/apps/api/src/app/agents/managed-runtime/setup/setup-card.helpers.ts similarity index 100% rename from apps/api/src/app/agents/usecases/managed-agent-setup/setup-card.helpers.ts rename to apps/api/src/app/agents/managed-runtime/setup/setup-card.helpers.ts diff --git a/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts similarity index 97% rename from apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts index 8f46ec7a5a1..52b825b1bad 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/approval-card.builder.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/approval-card.builder.ts @@ -65,10 +65,6 @@ function buildPersistTrustActionId( return `${TOOL_APPROVAL_ACTION_PREFIX}:${verdict}:${tool.toolUseId}:${turnId}:${toolName}:${mcpServerName}`; } -export function isLinkButtonActionId(id: string | undefined): boolean { - return typeof id === 'string' && id.startsWith('link-'); -} - export function extractPendingToolApprovals(response: ThalamusResponse): PendingToolApproval[] { const actions = response.actionsRequired; if (!Array.isArray(actions) || actions.length === 0) { diff --git a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.command.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.command.ts similarity index 91% rename from apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.command.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.command.ts index 0e8d668d62e..577977c324e 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.command.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.command.ts @@ -1,7 +1,7 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { AgentPlatformEnum } from '../../dtos/agent-platform.enum'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; import type { ParsedToolApprovalAction } from './approval-card.builder'; export class ConfirmToolApprovalCommand extends EnvironmentWithUserCommand { diff --git a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts similarity index 92% rename from apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts index 77c082ff155..780dd142dc1 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/confirm-tool-approval.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/confirm-tool-approval.usecase.ts @@ -6,13 +6,12 @@ import { McpConnectionRepository, SubscriberRepository, } from '@novu/dal'; - -import { ManagedAgentService } from '../../services/managed-agent.service'; -import { ManagedAgentProviderFactory } from '../../services/managed-agent-provider-factory'; -import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; -import { HandlePlanProgressCommand } from '../handle-plan-progress/handle-plan-progress.command'; -import { HandlePlanProgress } from '../handle-plan-progress/handle-plan-progress.usecase'; +import { HandleAgentReplyCommand } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { HandlePlanProgressCommand } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command'; +import { HandlePlanProgress } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase'; +import { ManagedAgentService } from '../managed-agent.service'; +import { ManagedAgentProviderFactory } from '../managed-agent-provider-factory.service'; import { buildToolApprovalVerdictCard, type ParsedToolApprovalAction } from './approval-card.builder'; import { ConfirmToolApprovalCommand } from './confirm-tool-approval.command'; import { mergeToolTrustPatch, resolveTrustForPendingTool } from './tool-trust.helper'; diff --git a/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.command.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.command.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.command.ts diff --git a/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts similarity index 92% rename from apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts index 12a6248a90a..9151bb4a25e 100644 --- a/apps/api/src/app/agents/usecases/tool-approval/handle-pending-tool-approvals.usecase.ts +++ b/apps/api/src/app/agents/managed-runtime/tool-approval/handle-pending-tool-approvals.usecase.ts @@ -7,14 +7,14 @@ import { McpConnectionRepository, SubscriberRepository, } from '@novu/dal'; -import { AgentPlatformEnum } from '../../dtos/agent-platform.enum'; -import { ManagedAgentService } from '../../services/managed-agent.service'; -import { ManagedAgentProviderFactory } from '../../services/managed-agent-provider-factory'; -import { captureAgentException, captureAgentWarning } from '../../utils/capture-agent-sentry'; -import { HandleAgentReplyCommand } from '../handle-agent-reply/handle-agent-reply.command'; -import { HandleAgentReply } from '../handle-agent-reply/handle-agent-reply.usecase'; -import { HandlePlanProgressCommand } from '../handle-plan-progress/handle-plan-progress.command'; -import { HandlePlanProgress } from '../handle-plan-progress/handle-plan-progress.usecase'; +import { HandleAgentReplyCommand } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command'; +import { HandleAgentReply } from '../../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { HandlePlanProgressCommand } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.command'; +import { HandlePlanProgress } from '../../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase'; +import { AgentPlatformEnum } from '../../shared/enums/agent-platform.enum'; +import { captureAgentException, captureAgentWarning } from '../../shared/errors/capture-agent-sentry'; +import { ManagedAgentService } from '../managed-agent.service'; +import { ManagedAgentProviderFactory } from '../managed-agent-provider-factory.service'; import { buildToolApprovalCard, extractPendingToolApprovals } from './approval-card.builder'; import { HandlePendingToolApprovalsCommand } from './handle-pending-tool-approvals.command'; import { resolveTrustForPendingTool } from './tool-trust.helper'; diff --git a/apps/api/src/app/agents/usecases/tool-approval/tool-trust.helper.ts b/apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.helper.ts similarity index 100% rename from apps/api/src/app/agents/usecases/tool-approval/tool-trust.helper.ts rename to apps/api/src/app/agents/managed-runtime/tool-approval/tool-trust.helper.ts diff --git a/apps/api/src/app/agents/management/agent-runtime.controller.ts b/apps/api/src/app/agents/management/agent-runtime.controller.ts new file mode 100644 index 00000000000..1213eb44042 --- /dev/null +++ b/apps/api/src/app/agents/management/agent-runtime.controller.ts @@ -0,0 +1,485 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + Req, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; +import { RequirePermissions } from '@novu/application-generic'; +import { ApiRateLimitCategoryEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; +import type { Request } from 'express'; +import { RequireAuthentication } from '../../auth/framework/auth.decorator'; +import { ExternalApiAccessible } from '../../auth/framework/external-api.decorator'; +import { ThrottlerCategory } from '../../rate-limiting/guards'; +import { + ApiCommonResponses, + ApiConflictResponse, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiResponse, +} from '../../shared/framework/response.decorator'; +import { UserSession } from '../../shared/framework/user.decorator'; +import { GetMcpConnectionStatusCommand } from '../mcp/connections/get-mcp-connection-status/get-mcp-connection-status.command'; +import { GetMcpConnectionStatus } from '../mcp/connections/get-mcp-connection-status/get-mcp-connection-status.usecase'; +import { GenerateMcpOAuthUrlCommand } from '../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.command'; +import { GenerateMcpOAuthUrl } from '../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; +import { DisableAgentMcpServerCommand } from '../mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.command'; +import { DisableAgentMcpServer } from '../mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.usecase'; +import { EnableAgentMcpServerCommand } from '../mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.command'; +import { EnableAgentMcpServer } from '../mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.usecase'; +import { ListAgentMcpServersCommand } from '../mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.command'; +import { ListAgentMcpServers } from '../mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.usecase'; +import { SetAgentMcpServersCommand } from '../mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.command'; +import { SetAgentMcpServers } from '../mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.usecase'; +import { AgentRuntimeExceptionFilter } from '../shared/agent-runtime-exception.filter'; +import { + AgentMcpServerEnablementResponseDto, + AgentRuntimeConfigResponseDto, + EnableAgentMcpServerRequestDto, + GenerateManagedAgentRequestDto, + GenerateManagedAgentResponseDto, + GenerateMcpOAuthUrlRequestDto, + GenerateMcpOAuthUrlResponseDto, + ListAgentMcpServersResponseDto, + McpConnectionResponseDto, + MigrateAgentRuntimeRequestDto, + PatchAgentRuntimeConfigRequestDto, + SetAgentMcpServersRequestDto, + SetAgentMcpServersResponseDto, + UploadCustomSkillRequestDto, + UploadCustomSkillResponseDto, + VerifyManagedCredentialsRequestDto, + VerifyManagedCredentialsResponseDto, +} from '../shared/dtos'; +import { GenerateManagedAgentCommand } from './usecases/generate-managed-agent/generate-managed-agent.command'; +import { GenerateManagedAgent } from './usecases/generate-managed-agent/generate-managed-agent.usecase'; +import { GetAgentDemoQuotaCommand } from './usecases/get-agent-demo-quota/get-agent-demo-quota.command'; +import { GetAgentDemoQuota } from './usecases/get-agent-demo-quota/get-agent-demo-quota.usecase'; +import { GetAgentRuntimeConfigCommand } from './usecases/get-agent-runtime-config/get-agent-runtime-config.command'; +import { GetAgentRuntimeConfig } from './usecases/get-agent-runtime-config/get-agent-runtime-config.usecase'; +import { MigrateAgentRuntimeCommand } from './usecases/migrate-agent-runtime/migrate-agent-runtime.command'; +import { MigrateAgentRuntime } from './usecases/migrate-agent-runtime/migrate-agent-runtime.usecase'; +import { UpdateAgentRuntimeConfigCommand } from './usecases/update-agent-runtime-config/update-agent-runtime-config.command'; +import { UpdateAgentRuntimeConfig } from './usecases/update-agent-runtime-config/update-agent-runtime-config.usecase'; +import { UploadCustomSkillCommand } from './usecases/upload-custom-skill/upload-custom-skill.command'; +import { UploadCustomSkill } from './usecases/upload-custom-skill/upload-custom-skill.usecase'; +import { VerifyManagedCredentialsCommand } from './usecases/verify-managed-credentials/verify-managed-credentials.command'; +import { VerifyManagedCredentials } from './usecases/verify-managed-credentials/verify-managed-credentials.usecase'; + +@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) +@ApiCommonResponses() +@Controller('/agents') +@UseInterceptors(ClassSerializerInterceptor) +@ApiExcludeController() +@RequireAuthentication() +export class AgentRuntimeController { + constructor( + private readonly getAgentRuntimeConfigUsecase: GetAgentRuntimeConfig, + private readonly updateAgentRuntimeConfigUsecase: UpdateAgentRuntimeConfig, + private readonly uploadCustomSkillUsecase: UploadCustomSkill, + private readonly enableAgentMcpServerUsecase: EnableAgentMcpServer, + private readonly disableAgentMcpServerUsecase: DisableAgentMcpServer, + private readonly setAgentMcpServersUsecase: SetAgentMcpServers, + private readonly listAgentMcpServersUsecase: ListAgentMcpServers, + private readonly generateMcpOAuthUrlUsecase: GenerateMcpOAuthUrl, + private readonly getMcpConnectionStatusUsecase: GetMcpConnectionStatus, + private readonly verifyManagedCredentialsUsecase: VerifyManagedCredentials, + private readonly generateManagedAgentUsecase: GenerateManagedAgent, + private readonly getAgentDemoQuotaUsecase: GetAgentDemoQuota, + private readonly migrateAgentRuntimeUsecase: MigrateAgentRuntime + ) {} + + @Post('/verify-credentials') + @ExternalApiAccessible() + @ApiResponse(VerifyManagedCredentialsResponseDto) + @ApiOperation({ + summary: 'Verify managed-runtime credentials', + description: + 'Performs a stateless, read-only validation of the supplied API key against the selected managed-runtime provider. ' + + 'Used by the dashboard to give immediate feedback when configuring credentials before the integration is created.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + verifyManagedCredentials( + @UserSession() user: UserSessionData, + @Body() body: VerifyManagedCredentialsRequestDto + ): Promise { + return this.verifyManagedCredentialsUsecase.execute( + VerifyManagedCredentialsCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + providerId: body.providerId, + apiKey: body.apiKey, + externalWorkspaceId: body.externalWorkspaceId, + region: body.region, + }) + ); + } + + @Post('/generate') + @ExternalApiAccessible() + @ApiResponse(GenerateManagedAgentResponseDto) + @ApiOperation({ + summary: 'Generate an agent configuration from a free-form prompt', + description: + 'Translates a user-supplied description into an agent configuration (name, identifier, systemPrompt, tools, MCP servers, skills).', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + async generateManagedAgent( + @UserSession() user: UserSessionData, + @Body() body: GenerateManagedAgentRequestDto, + @Req() request: Request + ): Promise { + const abortController = new AbortController(); + const handleSocketClose = (): void => { + if (request.destroyed) { + abortController.abort(); + } + }; + request.socket.on('close', handleSocketClose); + + const command = GenerateManagedAgentCommand.create({ + user, + prompt: body.prompt, + runtime: body.runtime, + }); + // Attach signal outside `create(...)` — running an `AbortSignal` through + // `class-transformer`'s `plainToInstance` triggers `new AbortSignal()`, which is + // disallowed by the runtime (`ERR_ILLEGAL_CONSTRUCTOR`). + command.signal = abortController.signal; + + try { + return await this.generateManagedAgentUsecase.execute(command); + } finally { + request.socket.off('close', handleSocketClose); + } + } + + @Get('/:identifier/demo-quota') + @ApiOperation({ + summary: 'Get Novu managed Claude demo quota', + description: + 'Returns monthly conversation and token usage limits for agents running on the Novu-managed Claude demo integration.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + getAgentDemoQuota(@UserSession() user: UserSessionData, @Param('identifier') identifier: string) { + return this.getAgentDemoQuotaUsecase.execute( + GetAgentDemoQuotaCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + }) + ); + } + + @Post('/:identifier/migrate-runtime') + @ApiOperation({ + summary: 'Migrate managed agent off Novu demo Claude credentials', + description: + 'Re-points a managed agent from the Novu demo Claude integration to a user-owned Anthropic integration, copying runtime config and clearing demo sessions.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + migrateAgentRuntime( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: MigrateAgentRuntimeRequestDto + ) { + return this.migrateAgentRuntimeUsecase.execute( + MigrateAgentRuntimeCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + integrationId: body.integrationId, + }) + ); + } + + @Get('/:identifier/runtime/config') + @ApiResponse(AgentRuntimeConfigResponseDto, 200) + @ApiOperation({ + summary: 'Get agent runtime config', + description: + 'Fetches the live runtime configuration for a managed agent from the provider ' + + '(model, system prompt, MCP servers, tools). Returns 422 for self-hosted agents.', + }) + @ApiNotFoundResponse({ description: 'Agent or its runtime integration was not found.' }) + @ApiConflictResponse({ + description: + 'AGENT_RUNTIME_DRIFT — the agent record exists in Novu but the provider reports it as deleted or unreachable. ' + + 'Re-provision or delete the agent.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + @UseFilters(AgentRuntimeExceptionFilter) + getAgentRuntimeConfig( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string + ): Promise { + return this.getAgentRuntimeConfigUsecase.execute( + GetAgentRuntimeConfigCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + }) + ); + } + + @Patch('/:identifier/runtime/config') + @ApiResponse(AgentRuntimeConfigResponseDto, 200) + @ApiOperation({ + summary: 'Update agent runtime config', + description: + 'Applies a partial update to the managed agent runtime config on the provider. ' + + 'Accepts any combination of model, systemPrompt, tools, and skills. ' + + 'MCP enablement is managed via the dedicated `POST /agents/:identifier/mcp-servers` and ' + + '`DELETE /agents/:identifier/mcp-servers/:mcpId` endpoints. ' + + 'Server-side diffing issues the minimal set of provider API calls. ' + + 'An empty body is accepted and returns the current config unchanged.', + }) + @ApiNotFoundResponse({ description: 'Agent or its runtime integration was not found.' }) + @ApiConflictResponse({ + description: + 'AGENT_RUNTIME_DRIFT — the agent record exists in Novu but the provider reports it as deleted or unreachable. ' + + 'Re-provision or delete the agent.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + updateAgentRuntimeConfig( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: PatchAgentRuntimeConfigRequestDto + ): Promise { + return this.updateAgentRuntimeConfigUsecase.execute( + UpdateAgentRuntimeConfigCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + model: body.model, + systemPrompt: body.systemPrompt, + tools: body.tools, + skills: body.skills, + }) + ); + } + + @Get('/:identifier/mcp-servers') + @ApiResponse(ListAgentMcpServersResponseDto) + @ApiOperation({ + summary: 'List MCP servers enabled on agent', + description: + 'Returns the per-agent enablement records sourced from Mongo. Mongo is the source of truth for ' + + 'the agent\u2019s MCP list; the provider\u2019s `agent.mcp_servers` collection is synced from these rows.', + }) + @ApiNotFoundResponse({ description: 'The agent was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + listAgentMcpServers( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string + ): Promise { + return this.listAgentMcpServersUsecase.execute( + ListAgentMcpServersCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + }) + ); + } + + @Post('/:identifier/mcp-servers') + @ApiResponse(AgentMcpServerEnablementResponseDto, 201) + @ApiOperation({ + summary: 'Enable an MCP server on agent', + description: + 'Writes the per-agent enablement record and synchronously projects the new enabled set onto the runtime provider.', + }) + @ApiNotFoundResponse({ description: 'The agent or runtime integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + enableAgentMcpServer( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: EnableAgentMcpServerRequestDto + ): Promise { + return this.enableAgentMcpServerUsecase.execute( + EnableAgentMcpServerCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + mcpId: body.mcpId, + defaultScope: body.defaultScope, + }) + ); + } + + @Put('/:identifier/mcp-servers') + @HttpCode(HttpStatus.OK) + @ApiResponse(SetAgentMcpServersResponseDto, 200) + @ApiOperation({ + summary: 'Replace the agent\u2019s enabled MCP server set', + description: + 'Idempotent bulk update: ids in the request not currently enabled are enabled, currently-enabled ids ' + + 'missing from the request are disabled, the rest are untouched. Catalog validation fails the whole ' + + 'request up-front (no partial writes for malformed input). Per-row business / provider errors are ' + + 'collected into `failed[]` so a single bad row never strands the other edits; the dashboard surfaces ' + + 'these failures and refetches the list to render the truth.', + }) + @ApiNotFoundResponse({ description: 'The agent or runtime integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + setAgentMcpServers( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: SetAgentMcpServersRequestDto + ): Promise { + return this.setAgentMcpServersUsecase.execute( + SetAgentMcpServersCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + mcpIds: body.mcpIds, + }) + ); + } + + @Delete('/:identifier/mcp-servers/:mcpId') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Disable an MCP server on agent', + description: + 'Cascade-deletes any `mcp_connection` rows scoped to this enablement, removes the per-agent record, and resyncs the provider projection.', + }) + @ApiNoContentResponse({ description: 'The MCP was disabled.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + disableAgentMcpServer( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('mcpId') mcpId: string + ): Promise { + return this.disableAgentMcpServerUsecase.execute( + DisableAgentMcpServerCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + mcpId, + }) + ); + } + + @Post('/:identifier/mcp-servers/:mcpId/oauth/url') + @HttpCode(HttpStatus.OK) + @ApiResponse(GenerateMcpOAuthUrlResponseDto, 200) + @ApiOperation({ + summary: 'Generate MCP OAuth authorize URL', + description: + 'Returns the provider authorize URL the subscriber should be redirected to for a `subscriber`-scoped connection. ' + + 'Reuses the signed-state OAuth pattern already used by chat integrations.', + }) + @ExternalApiAccessible() + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + generateMcpOAuthUrl( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('mcpId') mcpId: string, + @Body() body: GenerateMcpOAuthUrlRequestDto + ): Promise { + return this.generateMcpOAuthUrlUsecase.execute( + GenerateMcpOAuthUrlCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + mcpId, + subscriberId: body.subscriberId, + conversationId: body.conversationId, + }) + ); + } + + @Get('/:identifier/mcp-servers/:mcpId/connection') + @ApiResponse(McpConnectionResponseDto) + @ApiOperation({ + summary: 'Get MCP connection status for a subscriber', + description: + 'Returns the per-subscriber connection state for the (agent, mcp) pair, or null when no connection has been initiated yet. ' + + 'Used by the dashboard to render Authorize / Connected / Re-authorize CTAs without leaking encrypted tokens.', + }) + @ApiNotFoundResponse({ description: 'Agent or MCP enablement not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + getMcpConnectionStatus( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Param('mcpId') mcpId: string, + @Query('subscriberId') subscriberId: string + ): Promise { + return this.getMcpConnectionStatusUsecase.execute( + GetMcpConnectionStatusCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + agentIdentifier: identifier, + mcpId, + subscriberId, + }) + ); + } + + @Post('/skills') + @HttpCode(HttpStatus.CREATED) + @ApiResponse(UploadCustomSkillResponseDto, 201) + @ApiOperation({ + summary: 'Upload one or more custom skills from a source', + description: + 'Downloads the supplied source, uploads each resulting bundle to the integration provider ' + + 'as a custom skill, and returns the provider-assigned skill IDs as a uniform `skills[]` array. ' + + 'Three source variants are supported:\n\n' + + '- `type: "github-url"` — full `https://github.com/...` URL. Always uploads exactly one skill; ' + + 'use this form to pin a ref or to disambiguate when multiple repo directories share a basename. ' + + 'Accepts `/`, `/tree/{ref}`, or `/tree/{ref}/{path}` shapes.\n' + + '- `type: "github-repo"` — `owner/repo` slug fetched from the default branch (HEAD). ' + + 'Pass a required, non-empty `skills` array of directory basenames to upload. Each name must ' + + 'match exactly one directory containing a `SKILL.md`; ambiguous names are rejected with a 400.\n' + + '- `type: "inline"` — raw `SKILL.md` text pasted by the caller, wrapped server-side as a single-file bundle.\n\n' + + 'Each returned `skillId` can be passed via `managedRuntime.skills` on POST /agents or ' + + 'PATCH /agents/:identifier/runtime/config as `{ type: "custom", skillId }`. ' + + 'Re-uploading a source whose derived display title matches an existing custom skill appends a new ' + + 'version to it rather than failing — the entry returns the existing `skillId` and the new `version`. ' + + 'When a multi-skill `github-repo` upload partially fails, the request is aborted at the first ' + + 'error and earlier successful uploads are NOT rolled back (they will auto-version on retry).', + }) + @ApiNotFoundResponse({ description: 'The integration was not found.' }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + createCustomSkill( + @UserSession() user: UserSessionData, + @Body() body: UploadCustomSkillRequestDto + ): Promise { + return this.uploadCustomSkillUsecase.execute( + UploadCustomSkillCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + integrationId: body.integrationId, + source: body.source, + }) + ); + } +} diff --git a/apps/api/src/app/agents/management/agents.controller.ts b/apps/api/src/app/agents/management/agents.controller.ts new file mode 100644 index 00000000000..8132c7138f3 --- /dev/null +++ b/apps/api/src/app/agents/management/agents.controller.ts @@ -0,0 +1,243 @@ +import { + Body, + ClassSerializerInterceptor, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + UseFilters, + UseInterceptors, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation } from '@nestjs/swagger'; +import { RequirePermissions } from '@novu/application-generic'; +import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared'; +import { RequireAuthentication } from '../../auth/framework/auth.decorator'; +import { ExternalApiAccessible } from '../../auth/framework/external-api.decorator'; +import { ThrottlerCategory } from '../../rate-limiting/guards'; +import { + ApiCommonResponses, + ApiNoContentResponse, + ApiNotFoundResponse, + ApiResponse, +} from '../../shared/framework/response.decorator'; +import { UserSession } from '../../shared/framework/user.decorator'; +import { AgentRuntimeExceptionFilter } from '../shared/agent-runtime-exception.filter'; +import { + AgentResponseDto, + CreateAgentRequestDto, + ListAgentsQueryDto, + ListAgentsResponseDto, + UpdateAgentBridgeRequestDto, + UpdateAgentRequestDto, +} from '../shared/dtos'; +import { type AgentEmojiEntry, ListAgentEmoji } from '../shared/emoji/list-agent-emoji/list-agent-emoji.usecase'; +import { CreateAgentCommand } from './usecases/create-agent/create-agent.command'; +import { CreateAgent } from './usecases/create-agent/create-agent.usecase'; +import { DeleteAgentCommand } from './usecases/delete-agent/delete-agent.command'; +import { DeleteAgent } from './usecases/delete-agent/delete-agent.usecase'; +import { GetAgentCommand } from './usecases/get-agent/get-agent.command'; +import { GetAgent } from './usecases/get-agent/get-agent.usecase'; +import { ListAgentsCommand } from './usecases/list-agents/list-agents.command'; +import { ListAgents } from './usecases/list-agents/list-agents.usecase'; +import { UpdateAgentCommand } from './usecases/update-agent/update-agent.command'; +import { UpdateAgent } from './usecases/update-agent/update-agent.usecase'; + +@ThrottlerCategory(ApiRateLimitCategoryEnum.CONFIGURATION) +@ApiCommonResponses() +@Controller('/agents') +@UseInterceptors(ClassSerializerInterceptor) +@ApiExcludeController() +@RequireAuthentication() +export class AgentsController { + constructor( + private readonly createAgentUsecase: CreateAgent, + private readonly listAgentsUsecase: ListAgents, + private readonly getAgentUsecase: GetAgent, + private readonly updateAgentUsecase: UpdateAgent, + private readonly deleteAgentUsecase: DeleteAgent, + private readonly listAgentEmojiUsecase: ListAgentEmoji + ) {} + + @Get('/emoji') + @ApiOperation({ + summary: 'List available emoji', + description: + 'Returns the set of well-known cross-platform emoji names supported for agent reactions. ' + + 'Each entry includes the normalized name and a unicode representation for display.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + listAgentEmoji(): Promise { + return this.listAgentEmojiUsecase.execute(); + } + + @Post('/') + @ExternalApiAccessible() + @ApiResponse(AgentResponseDto, 201) + @ApiOperation({ + summary: 'Create agent', + description: 'Creates an agent scoped to the current environment. The identifier must be unique per environment.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + @UseFilters(AgentRuntimeExceptionFilter) + createAgent(@UserSession() user: UserSessionData, @Body() body: CreateAgentRequestDto): Promise { + return this.createAgentUsecase.execute( + CreateAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + name: body.name, + identifier: body.identifier, + description: body.description, + active: body.active, + runtime: body.runtime, + managedRuntime: body.managedRuntime, + }) + ); + } + + @Get('/') + @ExternalApiAccessible() + @ApiResponse(ListAgentsResponseDto) + @ApiOperation({ + summary: 'List agents', + description: + 'Returns a cursor-paginated list of agents for the current environment. Use **after**, **before**, **limit**, **orderBy**, and **orderDirection** query parameters.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + listAgents(@UserSession() user: UserSessionData, @Query() query: ListAgentsQueryDto): Promise { + return this.listAgentsUsecase.execute( + ListAgentsCommand.create({ + user, + environmentId: user.environmentId, + organizationId: user.organizationId, + limit: Number(query.limit || '10'), + after: query.after, + before: query.before, + orderDirection: query.orderDirection || DirectionEnum.DESC, + orderBy: query.orderBy || '_id', + includeCursor: query.includeCursor, + identifier: query.identifier, + }) + ); + } + + @Put('/:identifier/bridge') + @ApiResponse(AgentResponseDto) + @ApiOperation({ + summary: 'Update agent bridge configuration', + description: + 'Updates the bridge URL configuration for an agent. Used by the CLI to register dev tunnel URLs. Refuses to activate dev bridges on production environments.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @ExternalApiAccessible() + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateAgentBridge( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: UpdateAgentBridgeRequestDto + ): Promise { + return this.updateAgentUsecase.execute( + UpdateAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + bridgeUrl: body.bridgeUrl, + devBridgeUrl: body.devBridgeUrl, + devBridgeActive: body.devBridgeActive, + }) + ); + } + + @Get('/:identifier') + @ApiResponse(AgentResponseDto) + @ApiOperation({ + summary: 'Get agent', + description: 'Retrieves an agent by its external identifier (not the internal MongoDB id).', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_READ) + getAgent(@UserSession() user: UserSessionData, @Param('identifier') identifier: string): Promise { + return this.getAgentUsecase.execute( + GetAgentCommand.create({ + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + }) + ); + } + + @Patch('/:identifier') + @ApiResponse(AgentResponseDto) + @ApiOperation({ + summary: 'Update agent', + description: 'Updates an agent by its external identifier.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + updateAgent( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Body() body: UpdateAgentRequestDto + ): Promise { + return this.updateAgentUsecase.execute( + UpdateAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + name: body.name, + description: body.description, + active: body.active, + behavior: body.behavior, + bridgeUrl: body.bridgeUrl, + devBridgeUrl: body.devBridgeUrl, + devBridgeActive: body.devBridgeActive, + }) + ); + } + + @Delete('/:identifier') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete agent', + description: + 'Deletes an agent by identifier and removes all agent-integration links. ' + + 'For managed-runtime agents, pass `deleteFromProvider=true` to also archive the agent on the provider side (e.g. Anthropic). ' + + 'By default only the Novu record is deleted and the provider agent is left intact.', + }) + @ApiNoContentResponse({ + description: 'The agent was deleted.', + }) + @ApiNotFoundResponse({ + description: 'The agent was not found.', + }) + @RequirePermissions(PermissionsEnum.AGENT_WRITE) + deleteAgent( + @UserSession() user: UserSessionData, + @Param('identifier') identifier: string, + @Query('deleteFromProvider') deleteFromProvider?: string + ): Promise { + return this.deleteAgentUsecase.execute( + DeleteAgentCommand.create({ + userId: user._id, + environmentId: user.environmentId, + organizationId: user.organizationId, + identifier, + deleteFromProvider: deleteFromProvider === 'true', + }) + ); + } +} diff --git a/apps/api/src/app/agents/management/index.ts b/apps/api/src/app/agents/management/index.ts new file mode 100644 index 00000000000..898cb8b98fb --- /dev/null +++ b/apps/api/src/app/agents/management/index.ts @@ -0,0 +1,2 @@ +export { AgentRuntimeController } from './agent-runtime.controller'; +export { AgentsController } from './agents.controller'; diff --git a/apps/api/src/app/agents/utils/github-skill-bundle.ts b/apps/api/src/app/agents/management/skills/github-skill-bundle.ts similarity index 100% rename from apps/api/src/app/agents/utils/github-skill-bundle.ts rename to apps/api/src/app/agents/management/skills/github-skill-bundle.ts diff --git a/apps/api/src/app/agents/utils/inline-skill-bundle.ts b/apps/api/src/app/agents/management/skills/inline-skill-bundle.ts similarity index 100% rename from apps/api/src/app/agents/utils/inline-skill-bundle.ts rename to apps/api/src/app/agents/management/skills/inline-skill-bundle.ts diff --git a/apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts b/apps/api/src/app/agents/management/usecases/create-agent/create-agent.command.ts similarity index 83% rename from apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts rename to apps/api/src/app/agents/management/usecases/create-agent/create-agent.command.ts index 1bf262dcfca..b8ef6e55fcb 100644 --- a/apps/api/src/app/agents/usecases/create-agent/create-agent.command.ts +++ b/apps/api/src/app/agents/management/usecases/create-agent/create-agent.command.ts @@ -11,8 +11,8 @@ import { ValidateNested, } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { ManagedRuntimeDto } from '../../dtos/agent-runtime-config.dto'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; +import { ManagedRuntimeDto } from '../../../shared/dtos/agent-runtime-config.dto'; export class CreateAgentCommand extends EnvironmentWithUserCommand { @ValidateIf((o) => !o.managedRuntime?.externalAgentId) diff --git a/apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/create-agent/create-agent.usecase.ts similarity index 95% rename from apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/create-agent/create-agent.usecase.ts index 236ba6326d6..62d0f9586cb 100644 --- a/apps/api/src/app/agents/usecases/create-agent/create-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/create-agent/create-agent.usecase.ts @@ -8,10 +8,10 @@ import { } from '@novu/application-generic'; import { AgentRepository, CommunityOrganizationRepository, EnvironmentRepository } from '@novu/dal'; import { ApiServiceLevelEnum, EnvironmentTypeEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared'; -import { trackAgentCreated } from '../../agent-analytics'; -import type { AgentResponseDto } from '../../dtos'; -import { toAgentResponse } from '../../mappers/agent-response.mapper'; -import { FindOrCreateNovuEmail } from '../find-or-create-novu-email/find-or-create-novu-email.usecase'; +import { NovuEmailProvisioningService } from '../../../email/novu-email/find-or-create-novu-email/find-or-create-novu-email.service'; +import { trackAgentCreated } from '../../../shared/analytics/agent-analytics'; +import type { AgentResponseDto } from '../../../shared/dtos'; +import { toAgentResponse } from '../../../shared/mappers/agent-response.mapper'; import { ProvisionManagedAgentCommand } from '../provision-managed-agent/provision-managed-agent.command'; import { ProvisionManagedAgent } from '../provision-managed-agent/provision-managed-agent.usecase'; import { CreateAgentCommand } from './create-agent.command'; @@ -25,7 +25,7 @@ export class CreateAgent { private readonly agentRepository: AgentRepository, private readonly analyticsService: AnalyticsService, private readonly provisionManagedAgentUsecase: ProvisionManagedAgent, - private readonly findOrCreateNovuEmail: FindOrCreateNovuEmail, + private readonly findOrCreateNovuEmail: NovuEmailProvisioningService, private readonly environmentRepository: EnvironmentRepository, private readonly organizationRepository: CommunityOrganizationRepository, private readonly logger: PinoLogger @@ -190,7 +190,7 @@ export class CreateAgent { /** * Auto-provision the agent's default NovuAgent email integration so the * dashboard EMAIL INBOX card has something to render the moment the agent - * is created. Reuses `FindOrCreateNovuEmail` (idempotent). + * is created. Reuses `NovuEmailProvisioningService` (idempotent). * * Gates: * - cloud only (NOVU_ENTERPRISE + non self-hosted + shared inbound domain configured) diff --git a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts b/apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.command.ts similarity index 82% rename from apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts rename to apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.command.ts index ec3543443b2..bdd598e595d 100644 --- a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.command.ts +++ b/apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.command.ts @@ -1,6 +1,6 @@ import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class DeleteAgentCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.usecase.ts similarity index 92% rename from apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.usecase.ts index 30ed8201b2e..e233df8ab49 100644 --- a/apps/api/src/app/agents/usecases/delete-agent/delete-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/delete-agent/delete-agent.usecase.ts @@ -2,9 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, resolveAgentRuntime } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { AgentRuntimeProviderIdEnum } from '@novu/shared'; - -import { trackAgentDeleted } from '../../agent-analytics'; -import { CleanupNovuEmail } from '../cleanup-novu-email/cleanup-novu-email.usecase'; +import { NovuEmailCleanupService } from '../../../email/novu-email/cleanup-novu-email/cleanup-novu-email.service'; +import { trackAgentDeleted } from '../../../shared/analytics/agent-analytics'; import { DeleteAgentCommand } from './delete-agent.command'; @Injectable() @@ -13,7 +12,7 @@ export class DeleteAgent { private readonly agentRepository: AgentRepository, private readonly agentIntegrationRepository: AgentIntegrationRepository, private readonly integrationRepository: IntegrationRepository, - private readonly cleanupNovuEmail: CleanupNovuEmail, + private readonly cleanupNovuEmail: NovuEmailCleanupService, private readonly analyticsService: AnalyticsService ) {} diff --git a/apps/api/src/app/agents/usecases/generate-managed-agent/generate-managed-agent.command.ts b/apps/api/src/app/agents/management/usecases/generate-managed-agent/generate-managed-agent.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/generate-managed-agent/generate-managed-agent.command.ts rename to apps/api/src/app/agents/management/usecases/generate-managed-agent/generate-managed-agent.command.ts diff --git a/apps/api/src/app/agents/usecases/generate-managed-agent/generate-managed-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/generate-managed-agent/generate-managed-agent.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/generate-managed-agent/generate-managed-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/generate-managed-agent/generate-managed-agent.usecase.ts index 3860efb706c..fceba288c38 100644 --- a/apps/api/src/app/agents/usecases/generate-managed-agent/generate-managed-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/generate-managed-agent/generate-managed-agent.usecase.ts @@ -1,12 +1,7 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { ModuleRef } from '@nestjs/core'; import { AnalyticsService, InstrumentUsecase, PinoLogger } from '@novu/application-generic'; -import { - CLAUDE_ANTHROPIC_SKILLS, - CLAUDE_BUILTIN_TOOLS, - CLAUDE_DEFAULT_TOOL_TYPES, - MCP_SERVERS, -} from '@novu/shared'; +import { CLAUDE_ANTHROPIC_SKILLS, CLAUDE_BUILTIN_TOOLS, CLAUDE_DEFAULT_TOOL_TYPES, MCP_SERVERS } from '@novu/shared'; import { GenerateManagedAgentCommand } from './generate-managed-agent.command'; import { type ManagedAgentGenerationOutput, managedAgentGenerationSchema } from './managed-agent-generation.schema'; diff --git a/apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts b/apps/api/src/app/agents/management/usecases/generate-managed-agent/managed-agent-generation.schema.ts similarity index 100% rename from apps/api/src/app/agents/usecases/generate-managed-agent/managed-agent-generation.schema.ts rename to apps/api/src/app/agents/management/usecases/generate-managed-agent/managed-agent-generation.schema.ts diff --git a/apps/api/src/app/agents/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts b/apps/api/src/app/agents/management/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts similarity index 68% rename from apps/api/src/app/agents/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts rename to apps/api/src/app/agents/management/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts index ae45d22d14a..bf8d8186243 100644 --- a/apps/api/src/app/agents/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts +++ b/apps/api/src/app/agents/management/usecases/get-agent-demo-quota/get-agent-demo-quota.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import { EnvironmentCommand } from '../../../../shared/commands/project.command'; export class GetAgentDemoQuotaCommand extends EnvironmentCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/get-agent-demo-quota/get-agent-demo-quota.usecase.ts b/apps/api/src/app/agents/management/usecases/get-agent-demo-quota/get-agent-demo-quota.usecase.ts similarity index 100% rename from apps/api/src/app/agents/usecases/get-agent-demo-quota/get-agent-demo-quota.usecase.ts rename to apps/api/src/app/agents/management/usecases/get-agent-demo-quota/get-agent-demo-quota.usecase.ts diff --git a/apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts b/apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts similarity index 67% rename from apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts rename to apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts index d5095d9e509..8f06e4dc5bd 100644 --- a/apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts +++ b/apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.command.ts @@ -1,5 +1,5 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class GetAgentRuntimeConfigCommand extends EnvironmentWithUserCommand { @IsNotEmpty() diff --git a/apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts b/apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts similarity index 94% rename from apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts rename to apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts index b68398d0bdc..1d1da0905b1 100644 --- a/apps/api/src/app/agents/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase.ts @@ -2,8 +2,11 @@ import { Injectable, NotFoundException, UnprocessableEntityException } from '@ne import { PinoLogger, resolveAgentRuntime } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { AGENT_RUNTIME_PROVIDERS } from '@novu/shared'; -import type { AgentRuntimeCapabilitiesDto, AgentRuntimeConfigResponseDto } from '../../dtos/agent-runtime-config.dto'; -import { projectMcpRowsToCatalog } from '../../utils/project-mcp-servers'; +import { projectMcpRowsToCatalog } from '../../../mcp/project-mcp-servers'; +import type { + AgentRuntimeCapabilitiesDto, + AgentRuntimeConfigResponseDto, +} from '../../../shared/dtos/agent-runtime-config.dto'; import { GetAgentRuntimeConfigCommand } from './get-agent-runtime-config.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts b/apps/api/src/app/agents/management/usecases/get-agent/get-agent.command.ts similarity index 67% rename from apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts rename to apps/api/src/app/agents/management/usecases/get-agent/get-agent.command.ts index 8dc95b097c8..42fc86fb505 100644 --- a/apps/api/src/app/agents/usecases/get-agent/get-agent.command.ts +++ b/apps/api/src/app/agents/management/usecases/get-agent/get-agent.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import { EnvironmentCommand } from '../../../../shared/commands/project.command'; export class GetAgentCommand extends EnvironmentCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/get-agent/get-agent.usecase.ts similarity index 94% rename from apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/get-agent/get-agent.usecase.ts index 7ed01916aae..9db3824e97d 100644 --- a/apps/api/src/app/agents/usecases/get-agent/get-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/get-agent/get-agent.usecase.ts @@ -1,8 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { decryptCredentials } from '@novu/application-generic'; import { AgentRepository, IntegrationRepository } from '@novu/dal'; -import type { AgentResponseDto } from '../../dtos'; -import { type ManagedRuntimeHydration, toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentResponseDto } from '../../../shared/dtos'; +import { type ManagedRuntimeHydration, toAgentResponse } from '../../../shared/mappers/agent-response.mapper'; import { GetAgentCommand } from './get-agent.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/list-agents/list-agents.command.ts b/apps/api/src/app/agents/management/usecases/list-agents/list-agents.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/list-agents/list-agents.command.ts rename to apps/api/src/app/agents/management/usecases/list-agents/list-agents.command.ts diff --git a/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts b/apps/api/src/app/agents/management/usecases/list-agents/list-agents.usecase.ts similarity index 94% rename from apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts rename to apps/api/src/app/agents/management/usecases/list-agents/list-agents.usecase.ts index 186896623e7..ca2637900ee 100644 --- a/apps/api/src/app/agents/usecases/list-agents/list-agents.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/list-agents/list-agents.usecase.ts @@ -2,9 +2,9 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { InstrumentUsecase } from '@novu/application-generic'; import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { DirectionEnum } from '@novu/shared'; -import type { AgentIntegrationSummaryDto } from '../../dtos/agent-integration-summary.dto'; -import { ListAgentsResponseDto } from '../../dtos/list-agents-response.dto'; -import { toAgentIntegrationSummary, toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationSummaryDto } from '../../../shared/dtos/agent-integration-summary.dto'; +import { ListAgentsResponseDto } from '../../../shared/dtos/list-agents-response.dto'; +import { toAgentIntegrationSummary, toAgentResponse } from '../../../shared/mappers/agent-response.mapper'; import { ListAgentsCommand } from './list-agents.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts b/apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts similarity index 72% rename from apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts rename to apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts index 5bd15e47a92..f265b9b79e1 100644 --- a/apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts +++ b/apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.command.ts @@ -1,6 +1,6 @@ import { IsMongoId, IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class MigrateAgentRuntimeCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.spec.ts b/apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.spec.ts similarity index 100% rename from apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.spec.ts rename to apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.spec.ts diff --git a/apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.ts b/apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.ts similarity index 100% rename from apps/api/src/app/agents/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.ts rename to apps/api/src/app/agents/management/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase.ts diff --git a/apps/api/src/app/agents/usecases/provision-managed-agent/provision-managed-agent.command.ts b/apps/api/src/app/agents/management/usecases/provision-managed-agent/provision-managed-agent.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/provision-managed-agent/provision-managed-agent.command.ts rename to apps/api/src/app/agents/management/usecases/provision-managed-agent/provision-managed-agent.command.ts diff --git a/apps/api/src/app/agents/usecases/provision-managed-agent/provision-managed-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/provision-managed-agent/provision-managed-agent.usecase.ts similarity index 99% rename from apps/api/src/app/agents/usecases/provision-managed-agent/provision-managed-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/provision-managed-agent/provision-managed-agent.usecase.ts index ba29b11bc31..29fb6160a85 100644 --- a/apps/api/src/app/agents/usecases/provision-managed-agent/provision-managed-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/provision-managed-agent/provision-managed-agent.usecase.ts @@ -6,13 +6,13 @@ import { getAgentRuntimeProvider, getNovuManagedClaudeApiKey, PinoLogger, - resolveAgentRuntime, type ResolvedAgentRuntime, + resolveAgentRuntime, } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { AgentRuntimeProviderIdEnum, type ICredentialsDto, MCP_SERVERS, McpConnectionScopeEnum } from '@novu/shared'; import type { ClientSession } from 'mongoose'; -import { resolveMcpServersById } from '../../utils/resolve-mcp-servers'; +import { resolveMcpServersById } from '../../../mcp/resolve-mcp-servers'; import { ProvisionManagedAgentCommand } from './provision-managed-agent.command'; export type ProvisionManagedAgentOptions = { diff --git a/apps/api/src/app/agents/usecases/sync-agent-to-environment/index.ts b/apps/api/src/app/agents/management/usecases/sync-agent-to-environment/index.ts similarity index 100% rename from apps/api/src/app/agents/usecases/sync-agent-to-environment/index.ts rename to apps/api/src/app/agents/management/usecases/sync-agent-to-environment/index.ts diff --git a/apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts b/apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts similarity index 74% rename from apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts rename to apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts index 0e95c0e2d9e..47f03fe13b3 100644 --- a/apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts +++ b/apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class SyncAgentToEnvironmentCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.spec.ts b/apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.spec.ts similarity index 100% rename from apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.spec.ts rename to apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.spec.ts diff --git a/apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.usecase.ts b/apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.usecase.ts similarity index 100% rename from apps/api/src/app/agents/usecases/sync-agent-to-environment/sync-agent-to-environment.usecase.ts rename to apps/api/src/app/agents/management/usecases/sync-agent-to-environment/sync-agent-to-environment.usecase.ts diff --git a/apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts b/apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts similarity index 73% rename from apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts rename to apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts index d2be902b960..f5df9fb7dce 100644 --- a/apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.command.ts @@ -1,6 +1,6 @@ import { IsBoolean, IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class UpdateAgentInboxSharedCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts b/apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts similarity index 96% rename from apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts rename to apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts index 6fe7612a97c..8a776882c61 100644 --- a/apps/api/src/app/agents/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase.ts @@ -3,8 +3,8 @@ import { InstrumentUsecase, isAgentSharedInboxEnabled } from '@novu/application- import { AgentIntegrationRepository, AgentRepository, DomainRouteRepository, IntegrationRepository } from '@novu/dal'; import { ChannelTypeEnum, DomainRouteTypeEnum, EmailProviderIdEnum } from '@novu/shared'; -import type { AgentIntegrationResponseDto } from '../../dtos'; -import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper'; +import type { AgentIntegrationResponseDto } from '../../../shared/dtos'; +import { toAgentIntegrationResponse } from '../../../shared/mappers/agent-response.mapper'; import { UpdateAgentInboxSharedCommand } from './update-agent-inbox-shared.command'; /** diff --git a/apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts b/apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts similarity index 60% rename from apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts rename to apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts index 27bf64d5e3c..cc7cf750b3c 100644 --- a/apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import type { AgentSkillInputDto, AgentToolDto } from '../../dtos/agent-runtime-config.dto'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; +import type { AgentSkillInputDto, AgentToolDto } from '../../../shared/dtos/agent-runtime-config.dto'; export class UpdateAgentRuntimeConfigCommand extends EnvironmentWithUserCommand { @IsNotEmpty() diff --git a/apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts b/apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts similarity index 94% rename from apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts rename to apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts index e2faff6abce..892a1474c1a 100644 --- a/apps/api/src/app/agents/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase.ts @@ -2,8 +2,11 @@ import { Injectable, NotFoundException, UnprocessableEntityException } from '@ne import { PinoLogger, resolveAgentRuntime } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { AGENT_RUNTIME_PROVIDERS } from '@novu/shared'; -import type { AgentRuntimeCapabilitiesDto, AgentRuntimeConfigResponseDto } from '../../dtos/agent-runtime-config.dto'; -import { projectMcpRowsToCatalog } from '../../utils/project-mcp-servers'; +import { projectMcpRowsToCatalog } from '../../../mcp/project-mcp-servers'; +import type { + AgentRuntimeCapabilitiesDto, + AgentRuntimeConfigResponseDto, +} from '../../../shared/dtos/agent-runtime-config.dto'; import { UpdateAgentRuntimeConfigCommand } from './update-agent-runtime-config.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts b/apps/api/src/app/agents/management/usecases/update-agent/update-agent.command.ts similarity index 82% rename from apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts rename to apps/api/src/app/agents/management/usecases/update-agent/update-agent.command.ts index 437be691973..8c3203f0181 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.command.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent/update-agent.command.ts @@ -1,8 +1,8 @@ import { Type } from 'class-transformer'; import { IsBoolean, IsNotEmpty, IsOptional, IsString, IsUrl, ValidateIf, ValidateNested } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { AgentBehaviorDto } from '../../dtos/agent-behavior.dto'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; +import { AgentBehaviorDto } from '../../../shared/dtos/agent-behavior.dto'; export class UpdateAgentCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts b/apps/api/src/app/agents/management/usecases/update-agent/update-agent.usecase.ts similarity index 97% rename from apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts rename to apps/api/src/app/agents/management/usecases/update-agent/update-agent.usecase.ts index 7776e2dee00..9e219ca37c9 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/update-agent/update-agent.usecase.ts @@ -3,8 +3,8 @@ import { assertSafeOutboundUrl, resolvePublicAddresses, SsrfBlockedError } from import { AgentIntegrationRepository, AgentRepository, EnvironmentRepository, IntegrationRepository } from '@novu/dal'; import { EmailProviderIdEnum, EnvironmentTypeEnum } from '@novu/shared'; import type { ClientSession } from 'mongoose'; -import type { AgentResponseDto } from '../../dtos'; -import { toAgentResponse } from '../../mappers/agent-response.mapper'; +import type { AgentResponseDto } from '../../../shared/dtos'; +import { toAgentResponse } from '../../../shared/mappers/agent-response.mapper'; import { UpdateAgentCommand } from './update-agent.command'; @Injectable() diff --git a/apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.command.ts b/apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.command.ts similarity index 92% rename from apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.command.ts rename to apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.command.ts index 503c8ab5d57..fd44edbcc79 100644 --- a/apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.command.ts +++ b/apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.command.ts @@ -11,12 +11,12 @@ import { ValidateNested, } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; import { MAX_GITHUB_REPO_SKILLS_PER_REQUEST, MAX_INLINE_SKILL_CONTENT_LENGTH, type UploadCustomSkillSourceType, -} from '../../dtos/upload-custom-skill.dto'; +} from '../../../shared/dtos/upload-custom-skill.dto'; export class GithubUrlSkillSourceCommand { @IsIn(['github-url']) diff --git a/apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.usecase.ts b/apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.usecase.ts similarity index 97% rename from apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.usecase.ts rename to apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.usecase.ts index ac772b63c4d..6d66cf90654 100644 --- a/apps/api/src/app/agents/usecases/upload-custom-skill/upload-custom-skill.usecase.ts +++ b/apps/api/src/app/agents/management/usecases/upload-custom-skill/upload-custom-skill.usecase.ts @@ -7,7 +7,7 @@ import { } from '@novu/application-generic'; import { IntegrationRepository } from '@novu/dal'; -import type { UploadCustomSkillSourceType } from '../../dtos/upload-custom-skill.dto'; +import type { UploadCustomSkillSourceType } from '../../../shared/dtos/upload-custom-skill.dto'; import { assertRepoSlug, buildRepoSkillDisplayTitle, @@ -17,8 +17,8 @@ import { fetchAndExtractSkillBundle, parseGithubUrl, parseSkillNameFromFrontmatter, -} from '../../utils/github-skill-bundle'; -import { buildInlineSkillBundle } from '../../utils/inline-skill-bundle'; +} from '../../skills/github-skill-bundle'; +import { buildInlineSkillBundle } from '../../skills/inline-skill-bundle'; import { UploadCustomSkillCommand, type UploadCustomSkillSource } from './upload-custom-skill.command'; export type UploadedSkillEntry = { diff --git a/apps/api/src/app/agents/usecases/verify-managed-credentials/verify-managed-credentials.command.ts b/apps/api/src/app/agents/management/usecases/verify-managed-credentials/verify-managed-credentials.command.ts similarity index 61% rename from apps/api/src/app/agents/usecases/verify-managed-credentials/verify-managed-credentials.command.ts rename to apps/api/src/app/agents/management/usecases/verify-managed-credentials/verify-managed-credentials.command.ts index e1bb7ee1036..ab6678ed2b4 100644 --- a/apps/api/src/app/agents/usecases/verify-managed-credentials/verify-managed-credentials.command.ts +++ b/apps/api/src/app/agents/management/usecases/verify-managed-credentials/verify-managed-credentials.command.ts @@ -1,7 +1,7 @@ import { AgentRuntimeProviderIdEnum, AWS_CLAUDE_COMMERCIAL_REGIONS } from '@novu/shared'; import { IsEnum, IsIn, IsNotEmpty, IsString, ValidateIf } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class VerifyManagedCredentialsCommand extends EnvironmentWithUserCommand { @IsNotEmpty() @@ -12,12 +12,16 @@ export class VerifyManagedCredentialsCommand extends EnvironmentWithUserCommand @IsNotEmpty() apiKey: string; - @ValidateIf((command: VerifyManagedCredentialsCommand) => command.providerId === AgentRuntimeProviderIdEnum.AnthropicAws) + @ValidateIf( + (command: VerifyManagedCredentialsCommand) => command.providerId === AgentRuntimeProviderIdEnum.AnthropicAws + ) @IsString() @IsNotEmpty() externalWorkspaceId?: string; - @ValidateIf((command: VerifyManagedCredentialsCommand) => command.providerId === AgentRuntimeProviderIdEnum.AnthropicAws) + @ValidateIf( + (command: VerifyManagedCredentialsCommand) => command.providerId === AgentRuntimeProviderIdEnum.AnthropicAws + ) @IsString() @IsNotEmpty() @IsIn([...AWS_CLAUDE_COMMERCIAL_REGIONS]) diff --git a/apps/api/src/app/agents/usecases/verify-managed-credentials/verify-managed-credentials.usecase.ts b/apps/api/src/app/agents/management/usecases/verify-managed-credentials/verify-managed-credentials.usecase.ts similarity index 100% rename from apps/api/src/app/agents/usecases/verify-managed-credentials/verify-managed-credentials.usecase.ts rename to apps/api/src/app/agents/management/usecases/verify-managed-credentials/verify-managed-credentials.usecase.ts diff --git a/apps/api/src/app/agents/services/assert-mcp-novu-app-flag-enabled.ts b/apps/api/src/app/agents/mcp/assert-mcp-novu-app-flag-enabled.ts similarity index 100% rename from apps/api/src/app/agents/services/assert-mcp-novu-app-flag-enabled.ts rename to apps/api/src/app/agents/mcp/assert-mcp-novu-app-flag-enabled.ts diff --git a/apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.command.ts b/apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.command.ts similarity index 80% rename from apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.command.ts rename to apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.command.ts index 4085bd2b974..5ac4dddf8e0 100644 --- a/apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.command.ts +++ b/apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class GetMcpConnectionStatusCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.usecase.ts b/apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.usecase.ts similarity index 97% rename from apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.usecase.ts rename to apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.usecase.ts index ab52bd69e1b..aead9a962a6 100644 --- a/apps/api/src/app/agents/usecases/get-mcp-connection-status/get-mcp-connection-status.usecase.ts +++ b/apps/api/src/app/agents/mcp/connections/get-mcp-connection-status/get-mcp-connection-status.usecase.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AgentMcpServerRepository, AgentRepository, McpConnectionRepository, SubscriberRepository } from '@novu/dal'; import { McpConnectionAuthModeEnum, McpConnectionScopeEnum, McpConnectionStatusEnum } from '@novu/shared'; -import { McpConnectionResponseDto } from '../../dtos/mcp-server.dto'; +import { McpConnectionResponseDto } from '../../../shared/dtos/mcp-server.dto'; import { GetMcpConnectionStatusCommand } from './get-mcp-connection-status.command'; /** diff --git a/apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.spec.ts b/apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.spec.ts similarity index 94% rename from apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.spec.ts rename to apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.spec.ts index 132301240a5..4af960fa5a2 100644 --- a/apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.spec.ts +++ b/apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.spec.ts @@ -1,17 +1,17 @@ import { expect } from 'chai'; -import { McpOAuthDiscoveryError } from '../../services/mcp-oauth-discovery.service'; -import { GetMcpNovuAppCredentials } from './get-mcp-novu-app-credentials.usecase'; +import { McpOAuthDiscoveryError } from '../../oauth/mcp-oauth-discovery.service'; +import { McpNovuAppCredentialsService } from './get-mcp-novu-app-credentials.service'; -describe('GetMcpNovuAppCredentials', () => { - let usecase: GetMcpNovuAppCredentials; +describe('McpNovuAppCredentialsService', () => { + let usecase: McpNovuAppCredentialsService; let previousClientId: string | undefined; let previousClientSecret: string | undefined; beforeEach(() => { previousClientId = process.env.NOVU_GITHUB_MCP_APP_CLIENT_ID; previousClientSecret = process.env.NOVU_GITHUB_MCP_APP_CLIENT_SECRET; - usecase = new GetMcpNovuAppCredentials(); + usecase = new McpNovuAppCredentialsService(); }); afterEach(() => { diff --git a/apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.ts b/apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.ts similarity index 95% rename from apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.ts rename to apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.ts index 7d71b847b35..2cee6736d41 100644 --- a/apps/api/src/app/agents/usecases/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase.ts +++ b/apps/api/src/app/agents/mcp/connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { McpOAuthDiscoveryError } from '../../services/mcp-oauth-discovery.service'; +import { McpOAuthDiscoveryError } from '../../oauth/mcp-oauth-discovery.service'; export interface NovuAppCredentials { clientId: string; @@ -36,7 +36,7 @@ const CRED_ENV_MAP: RecordConnection complete

Connection complete. You can close this window.

`; const ERROR_FALLBACK_HTML = `Connection failed

Connection failed. You can close this window and try again.

`; diff --git a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts similarity index 85% rename from apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts rename to apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts index 0dfc1dd883e..f40b33caf8c 100644 --- a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts +++ b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class GenerateMcpOAuthUrlCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts rename to apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts index f547f5b36d2..92029f78735 100644 --- a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts +++ b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase.ts @@ -29,18 +29,18 @@ import { type McpServer, type NovuAppOAuthCatalogEntry, } from '@novu/shared'; -import { GenerateMcpOAuthUrlResponseDto } from '../../dtos/mcp-server.dto'; -import { assertMcpNovuAppFlagEnabled } from '../../services/assert-mcp-novu-app-flag-enabled'; +import { GenerateMcpOAuthUrlResponseDto } from '../../../shared/dtos/mcp-server.dto'; +import { assertMcpNovuAppFlagEnabled } from '../../assert-mcp-novu-app-flag-enabled'; +import { + McpNovuAppCredentialsService, + type NovuAppCredentials, +} from '../../connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service'; import { AuthorizationServerMetadata, DiscoveredProtectedResource, McpOAuthDiscoveryError, McpOAuthDiscoveryService, -} from '../../services/mcp-oauth-discovery.service'; -import { - GetMcpNovuAppCredentials, - type NovuAppCredentials, -} from '../get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase'; +} from '../mcp-oauth-discovery.service'; import { GenerateMcpOAuthUrlCommand } from './generate-mcp-oauth-url.command'; import { buildMcpOAuthRedirectUri, type McpOAuthState } from './mcp-oauth-state'; @@ -66,7 +66,7 @@ const SOFTWARE_VERSION = process.env.NOVU_API_VERSION || 'dev'; * failure), AS metadata discovery is skipped entirely, and the catalog * pins the authorize/token endpoints + scope list. The pre-registered * `client_id`/`client_secret` come from server env vars (resolved per - * request through `GetMcpNovuAppCredentials`). No `oauthClient` row is + * request through `McpNovuAppCredentialsService`). No `oauthClient` row is * persisted; instead the AS endpoints land on `oauthState` so the * callback can do the token exchange without re-consulting the catalog. * Gated by `IS_MCP_NOVU_APP_ENABLED`. @@ -87,7 +87,7 @@ export class GenerateMcpOAuthUrl { private readonly environmentRepository: EnvironmentRepository, private readonly subscriberRepository: SubscriberRepository, private readonly discoveryService: McpOAuthDiscoveryService, - private readonly getNovuAppCredentials: GetMcpNovuAppCredentials, + private readonly getNovuAppCredentials: McpNovuAppCredentialsService, private readonly featureFlagsService: FeatureFlagsService, private readonly logger: PinoLogger ) { diff --git a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/mcp-oauth-state.ts b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/mcp-oauth-state.ts similarity index 100% rename from apps/api/src/app/agents/usecases/generate-mcp-oauth-url/mcp-oauth-state.ts rename to apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/mcp-oauth-state.ts diff --git a/apps/api/src/app/agents/usecases/generate-mcp-oauth-url/mcp-oauth.constants.ts b/apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/mcp-oauth.constants.ts similarity index 100% rename from apps/api/src/app/agents/usecases/generate-mcp-oauth-url/mcp-oauth.constants.ts rename to apps/api/src/app/agents/mcp/oauth/generate-mcp-oauth-url/mcp-oauth.constants.ts diff --git a/apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.command.ts b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.command.ts similarity index 100% rename from apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.command.ts rename to apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.command.ts diff --git a/apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.usecase.spec.ts b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase.spec.ts similarity index 100% rename from apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.usecase.spec.ts rename to apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase.spec.ts diff --git a/apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.usecase.ts b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase.ts similarity index 97% rename from apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.usecase.ts rename to apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase.ts index 237944d14a8..52740a60235 100644 --- a/apps/api/src/app/agents/usecases/mcp-oauth-callback/mcp-oauth-callback.usecase.ts +++ b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase.ts @@ -25,20 +25,19 @@ import { McpConnectionStatusEnum, type McpOAuthCatalogEntry, } from '@novu/shared'; - -import { McpConnectionVaultService } from '../../services/mcp-connection-vault.service'; +import { CompleteManagedAgentSetup } from '../../../managed-runtime/setup/complete-managed-agent-setup.usecase'; +import { ManagedAgentSetupCompleteCommand } from '../../../managed-runtime/setup/managed-agent-setup-complete.command'; +import { McpNovuAppCredentialsService } from '../../connections/get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.service'; +import { McpConnectionVaultService } from '../../connections/mcp-connection-vault.service'; +import { SyncAgentMcpServersCommand } from '../../servers/sync-agent-mcp-servers/sync-agent-mcp-servers.command'; +import { SyncAgentMcpServers } from '../../servers/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; +import { MCP_OAUTH_STATE_TTL_MS } from '../generate-mcp-oauth-url/mcp-oauth.constants'; +import { buildMcpOAuthRedirectUri, type McpOAuthState } from '../generate-mcp-oauth-url/mcp-oauth-state'; import { McpOAuthDiscoveryError, McpOAuthDiscoveryService, type McpOAuthErrorCode, -} from '../../services/mcp-oauth-discovery.service'; -import { MCP_OAUTH_STATE_TTL_MS } from '../generate-mcp-oauth-url/mcp-oauth.constants'; -import { buildMcpOAuthRedirectUri, type McpOAuthState } from '../generate-mcp-oauth-url/mcp-oauth-state'; -import { GetMcpNovuAppCredentials } from '../get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase'; -import { CompleteManagedAgentSetup } from '../managed-agent-setup/complete-managed-agent-setup.usecase'; -import { ManagedAgentSetupCompleteCommand } from '../managed-agent-setup/managed-agent-setup-complete.command'; -import { SyncAgentMcpServersCommand } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.command'; -import { SyncAgentMcpServers } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; +} from '../mcp-oauth-discovery.service'; import { McpOAuthCallbackCommand, type McpOAuthCallbackResult } from './mcp-oauth-callback.command'; const MAX_ERROR_MESSAGE_LEN = 256; @@ -83,7 +82,7 @@ export class McpOAuthCallback { private readonly syncAgentMcpServers: SyncAgentMcpServers, private readonly mcpConnectionVaultService: McpConnectionVaultService, private readonly completeManagedAgentSetup: CompleteManagedAgentSetup, - private readonly getNovuAppCredentials: GetMcpNovuAppCredentials, + private readonly getNovuAppCredentials: McpNovuAppCredentialsService, private readonly logger: PinoLogger ) { this.logger.setContext(McpOAuthCallback.name); diff --git a/apps/api/src/app/agents/services/mcp-oauth-discovery.service.spec.ts b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-discovery.service.spec.ts similarity index 100% rename from apps/api/src/app/agents/services/mcp-oauth-discovery.service.spec.ts rename to apps/api/src/app/agents/mcp/oauth/mcp-oauth-discovery.service.spec.ts diff --git a/apps/api/src/app/agents/services/mcp-oauth-discovery.service.ts b/apps/api/src/app/agents/mcp/oauth/mcp-oauth-discovery.service.ts similarity index 100% rename from apps/api/src/app/agents/services/mcp-oauth-discovery.service.ts rename to apps/api/src/app/agents/mcp/oauth/mcp-oauth-discovery.service.ts diff --git a/apps/api/src/app/agents/utils/project-mcp-servers.ts b/apps/api/src/app/agents/mcp/project-mcp-servers.ts similarity index 100% rename from apps/api/src/app/agents/utils/project-mcp-servers.ts rename to apps/api/src/app/agents/mcp/project-mcp-servers.ts diff --git a/apps/api/src/app/agents/utils/resolve-mcp-servers.ts b/apps/api/src/app/agents/mcp/resolve-mcp-servers.ts similarity index 100% rename from apps/api/src/app/agents/utils/resolve-mcp-servers.ts rename to apps/api/src/app/agents/mcp/resolve-mcp-servers.ts diff --git a/apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.command.ts b/apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.command.ts similarity index 73% rename from apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.command.ts rename to apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.command.ts index c36ab258f4b..3a93c7c3a2b 100644 --- a/apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.command.ts +++ b/apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class DisableAgentMcpServerCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts b/apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts rename to apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts index daa45c7a682..5c7e33b9990 100644 --- a/apps/api/src/app/agents/usecases/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts +++ b/apps/api/src/app/agents/mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.usecase.ts @@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AnalyticsService, PinoLogger, resolveAgentRuntime } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository, IntegrationRepository, McpConnectionRepository } from '@novu/dal'; -import { trackAgentMcpServerDisabled } from '../../agent-analytics'; +import { trackAgentMcpServerDisabled } from '../../../shared/analytics/agent-analytics'; import { SyncAgentMcpServersCommand } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.command'; import { SyncAgentMcpServers } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; import { DisableAgentMcpServerCommand } from './disable-agent-mcp-server.command'; diff --git a/apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.command.ts b/apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.command.ts similarity index 82% rename from apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.command.ts rename to apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.command.ts index ec6a58e6837..dbb58697633 100644 --- a/apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.command.ts +++ b/apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.command.ts @@ -1,7 +1,7 @@ import { McpConnectionScopeEnum } from '@novu/shared'; import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class EnableAgentMcpServerCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts b/apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts similarity index 96% rename from apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts rename to apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts index 6e1b45f701c..d7f730c48b2 100644 --- a/apps/api/src/app/agents/usecases/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts +++ b/apps/api/src/app/agents/mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.usecase.ts @@ -2,10 +2,9 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } import { AnalyticsService, FeatureFlagsService } from '@novu/application-generic'; import { AgentMcpServerEntity, AgentMcpServerRepository, AgentRepository } from '@novu/dal'; import { MCP_SERVERS, McpConnectionAuthModeEnum, McpConnectionScopeEnum } from '@novu/shared'; - -import { trackAgentMcpServerEnabled } from '../../agent-analytics'; -import { AgentMcpServerEnablementResponseDto } from '../../dtos/mcp-server.dto'; -import { assertMcpNovuAppFlagEnabled } from '../../services/assert-mcp-novu-app-flag-enabled'; +import { trackAgentMcpServerEnabled } from '../../../shared/analytics/agent-analytics'; +import { AgentMcpServerEnablementResponseDto } from '../../../shared/dtos/mcp-server.dto'; +import { assertMcpNovuAppFlagEnabled } from '../../assert-mcp-novu-app-flag-enabled'; import { SyncAgentMcpServersCommand } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.command'; import { SyncAgentMcpServers } from '../sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; import { EnableAgentMcpServerCommand } from './enable-agent-mcp-server.command'; diff --git a/apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.command.ts b/apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.command.ts similarity index 68% rename from apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.command.ts rename to apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.command.ts index 40c26653080..46c9fde2486 100644 --- a/apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.command.ts +++ b/apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class ListAgentMcpServersCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts b/apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts similarity index 93% rename from apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts rename to apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts index b1af37007e5..8d5ba6cfa39 100644 --- a/apps/api/src/app/agents/usecases/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts +++ b/apps/api/src/app/agents/mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.usecase.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { AgentMcpServerRepository, AgentRepository } from '@novu/dal'; -import { ListAgentMcpServersResponseDto } from '../../dtos/mcp-server.dto'; +import { ListAgentMcpServersResponseDto } from '../../../shared/dtos/mcp-server.dto'; import { toEnablementResponse } from '../enable-agent-mcp-server/enable-agent-mcp-server.usecase'; import { ListAgentMcpServersCommand } from './list-agent-mcp-servers.command'; diff --git a/apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.command.ts b/apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.command.ts similarity index 76% rename from apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.command.ts rename to apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.command.ts index f4ed03d63a1..8951a18b1cc 100644 --- a/apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.command.ts +++ b/apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.command.ts @@ -1,6 +1,6 @@ import { ArrayUnique, IsArray, IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; +import { EnvironmentWithUserCommand } from '../../../../shared/commands/project.command'; export class SetAgentMcpServersCommand extends EnvironmentWithUserCommand { @IsString() diff --git a/apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts b/apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts rename to apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts index ed537dac541..7abd565be86 100644 --- a/apps/api/src/app/agents/usecases/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts +++ b/apps/api/src/app/agents/mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.usecase.ts @@ -3,7 +3,10 @@ import { PinoLogger } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository } from '@novu/dal'; import { MCP_SERVERS } from '@novu/shared'; -import { type SetAgentMcpServersFailureDto, type SetAgentMcpServersResponseDto } from '../../dtos/mcp-server.dto'; +import { + type SetAgentMcpServersFailureDto, + type SetAgentMcpServersResponseDto, +} from '../../../shared/dtos/mcp-server.dto'; import { DisableAgentMcpServerCommand } from '../disable-agent-mcp-server/disable-agent-mcp-server.command'; import { DisableAgentMcpServer } from '../disable-agent-mcp-server/disable-agent-mcp-server.usecase'; import { EnableAgentMcpServerCommand } from '../enable-agent-mcp-server/enable-agent-mcp-server.command'; diff --git a/apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts b/apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts similarity index 68% rename from apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts rename to apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts index 3b26f334df4..04be1ce2443 100644 --- a/apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts +++ b/apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.command.ts @@ -1,6 +1,6 @@ import { IsNotEmpty, IsString } from 'class-validator'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; +import { EnvironmentCommand } from '../../../../shared/commands/project.command'; export class SyncAgentMcpServersCommand extends EnvironmentCommand { @IsNotEmpty() diff --git a/apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts b/apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts similarity index 98% rename from apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts rename to apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts index 56d8a821440..2392f09f659 100644 --- a/apps/api/src/app/agents/usecases/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts +++ b/apps/api/src/app/agents/mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase.ts @@ -3,7 +3,7 @@ import { PinoLogger, resolveAgentRuntime } from '@novu/application-generic'; import { AgentMcpServerRepository, AgentRepository, IntegrationRepository } from '@novu/dal'; import { MCP_SERVERS } from '@novu/shared'; -import { projectMcpRowsToCatalog } from '../../utils/project-mcp-servers'; +import { projectMcpRowsToCatalog } from '../../project-mcp-servers'; import { SyncAgentMcpServersCommand } from './sync-agent-mcp-servers.command'; /** diff --git a/apps/api/src/app/agents/services/chat-sdk.service.spec.ts b/apps/api/src/app/agents/services/chat-sdk.service.spec.ts deleted file mode 100644 index ec5a159ce49..00000000000 --- a/apps/api/src/app/agents/services/chat-sdk.service.spec.ts +++ /dev/null @@ -1,697 +0,0 @@ -import type { IncomingHttpHeaders } from 'node:http'; -import { BadRequestException } from '@nestjs/common'; -import { MailFactory } from '@novu/application-generic'; -import { ChannelTypeEnum, EmailProviderIdEnum } from '@novu/shared'; -import { expect } from 'chai'; -import sinon from 'sinon'; -import { ChatSdkService } from './chat-sdk.service'; - -function makePinnedResponse({ - status = 200, - statusText = 'OK', - headers = {}, - data = Buffer.from('hello'), -}: { - status?: number; - statusText?: string; - headers?: IncomingHttpHeaders; - data?: Buffer; -} = {}) { - return { status, statusText, headers, data }; -} - -describe('ChatSdkService', () => { - function makeService() { - const logger = { - warn: sinon.stub(), - error: sinon.stub(), - debug: sinon.stub(), - info: sinon.stub(), - setContext: sinon.stub(), - }; - - return new ChatSdkService( - logger as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - { create: sinon.stub().resolves({ _id: 'm' }) } as any - ); - } - - describe('prepareContentForDelivery', () => { - it('should materialize files for card replies on supported platforms', async () => { - const service = makeService(); - const result = await (service as any).prepareContentForDelivery( - { - card: { type: 'card', title: 'Report', children: [] }, - files: [ - { - filename: 'sample.jpg', - mimeType: 'image/jpeg', - data: Buffer.from('hello').toString('base64'), - }, - ], - }, - 'whatsapp' - ); - - expect(result.card).to.exist; - expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); - expect(result.files[0].filename).to.equal('sample.jpg'); - }); - - it('should convert base64 file data to a Buffer before passing content to the chat SDK', async () => { - const service = makeService(); - const result = await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'sample.txt', - mimeType: 'text/plain', - data: Buffer.from('hello').toString('base64'), - }, - ], - }, - 'slack' - ); - - expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); - expect(result.files[0].data.toString()).to.equal('hello'); - expect(result.files[0].filename).to.equal('sample.txt'); - expect(result.files[0].mimeType).to.equal('text/plain'); - }); - - it('should reject non-string file data with a meaningful error', async () => { - const service = makeService(); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'sample.txt', - data: { type: 'Buffer', data: [104, 101, 108, 108, 111] }, - }, - ], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('Invalid file "sample.txt": data must be a base64-encoded string.'); - } - }); - - it('should reject invalid base64 file data with a meaningful error', async () => { - const service = makeService(); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'sample.txt', - data: 'not base64', - }, - ], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('Invalid file "sample.txt": data must be a base64-encoded string.'); - } - }); - - it('should reject inline file data over 5 MB', async () => { - const service = makeService(); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'large.bin', - data: Buffer.alloc(5 * 1024 * 1024 + 1).toString('base64'), - }, - ], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('inline data must be 5 MB or smaller'); - } - }); - - it('should fetch url file data to a Buffer and use response content-type as fallback mimeType', async () => { - const service = makeService(); - sinon.stub(service as any, 'validateFileUrl').resolves(null); - const requestStub = sinon.stub(service as any, 'requestPinnedFileUrl').resolves( - makePinnedResponse({ - headers: { - 'content-type': 'text/plain', - 'content-length': '5', - }, - }) - ); - - const result = await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'sample.txt', - url: 'https://example.com/sample.txt', - }, - ], - }, - 'slack' - ); - - expect(requestStub.calledOnceWith('https://example.com/sample.txt')).to.equal(true); - expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); - expect(result.files[0].data.toString()).to.equal('hello'); - expect(result.files[0].mimeType).to.equal('text/plain'); - expect(result.files[0].url).to.equal(undefined); - }); - - it('should validate redirected file urls before following them', async () => { - const service = makeService(); - const validateStub = sinon - .stub(service as any, 'validateFileUrl') - .onFirstCall() - .resolves(null) - .onSecondCall() - .resolves('Requests to "localhost" are not allowed.'); - const requestStub = sinon.stub(service as any, 'requestPinnedFileUrl').resolves( - makePinnedResponse({ - status: 302, - headers: { - location: 'http://localhost/private.txt', - }, - }) - ); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'sample.txt', url: 'https://example.com/sample.txt' }], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect(validateStub.callCount).to.equal(2); - expect(requestStub.calledOnceWith('https://example.com/sample.txt')).to.equal(true); - expect((err as Error).message).to.include('Requests to "localhost" are not allowed.'); - } - }); - - it('should reject SSRF-blocked file urls', async () => { - const service = makeService(); - sinon.stub(service as any, 'validateFileUrl').resolves('Requests to "localhost" are not allowed.'); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'sample.txt', url: 'http://localhost/sample.txt' }], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('Requests to "localhost" are not allowed.'); - } - }); - - it('should reject non-2xx file url responses', async () => { - const service = makeService(); - sinon.stub(service as any, 'validateFileUrl').resolves(null); - sinon - .stub(service as any, 'requestPinnedFileUrl') - .resolves(makePinnedResponse({ status: 404, statusText: 'Not Found' })); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'missing.txt', url: 'https://example.com/missing.txt' }], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('404 Not Found'); - } - }); - - it('should reject file urls with content-length over the per-file limit', async () => { - const service = makeService(); - sinon.stub(service as any, 'validateFileUrl').resolves(null); - sinon.stub(service as any, 'requestPinnedFileUrl').resolves( - makePinnedResponse({ - headers: { - 'content-length': String(26 * 1024 * 1024), - }, - }) - ); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'large.bin', url: 'https://example.com/large.bin' }], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('file size exceeds 25 MB'); - } - }); - - it('should reject streamed file url bodies over the per-file limit', async () => { - const service = makeService(); - sinon.stub(service as any, 'validateFileUrl').resolves(null); - sinon - .stub(service as any, 'requestPinnedFileUrl') - .rejects(new Error('Invalid file "large.bin": file size exceeds 25 MB.')); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'large.bin', url: 'https://example.com/large.bin' }], - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('file size exceeds 25 MB'); - } - }); - - it('should reject more than 15 files per message', async () => { - const service = makeService(); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here are the files', - files: Array.from({ length: 16 }, (_, index) => ({ - filename: `${index}.txt`, - data: Buffer.from('hello').toString('base64'), - })), - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('maximum is 15 files per message'); - } - }); - - it('should reject aggregate attachment size over 50 MB', async () => { - const service = makeService(); - sinon.stub(service as any, 'prepareFileForDelivery').callsFake(async (_file: unknown, index: number) => ({ - filename: `${index}.bin`, - data: Buffer.from('hello'), - size: 5 * 1024 * 1024, - source: 'url', - })); - - try { - await (service as any).prepareContentForDelivery( - { - markdown: 'Here are the files', - files: Array.from({ length: 11 }, (_, index) => ({ - filename: `${index}.bin`, - url: `https://example.com/${index}.bin`, - })), - }, - 'slack' - ); - throw new Error('Expected prepareContentForDelivery to throw'); - } catch (err) { - expect((err as Error).message).to.include('Total attachment size exceeds 50 MB'); - } - }); - - it('should drop files with a warning for email', async () => { - const logger = { - warn: sinon.stub(), - error: sinon.stub(), - debug: sinon.stub(), - info: sinon.stub(), - setContext: sinon.stub(), - }; - const service = new ChatSdkService( - logger as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - { create: sinon.stub().resolves({ _id: 'm' }) } as any - ); - - const result = await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [{ filename: 'sample.txt', data: Buffer.from('hello').toString('base64') }], - }, - 'email', - 'agent-id' - ); - - expect(result.files).to.equal(undefined); - expect(logger.warn.calledOnce).to.equal(true); - expect(logger.warn.firstCall.args[0]).to.deep.include({ - agentId: 'agent-id', - platform: 'email', - droppedCount: 1, - }); - }); - - it('should convert base64 file data to a Buffer for whatsapp', async () => { - const service = makeService(); - const result = await (service as any).prepareContentForDelivery( - { - markdown: 'Here is the file', - files: [ - { - filename: 'sample.txt', - mimeType: 'text/plain', - data: Buffer.from('hello').toString('base64'), - }, - ], - }, - 'whatsapp' - ); - - expect(Buffer.isBuffer(result.files[0].data)).to.equal(true); - expect(result.files[0].data.toString()).to.equal('hello'); - expect(result.files[0].filename).to.equal('sample.txt'); - expect(result.files[0].mimeType).to.equal('text/plain'); - }); - }); - - describe('buildSendEmailCallback', () => { - it('should skip custom MIME alternatives for unsupported outbound providers', async () => { - const logger = { - warn: sinon.stub(), - error: sinon.stub(), - debug: sinon.stub(), - info: sinon.stub(), - setContext: sinon.stub(), - }; - const integrationRepository = { - findOne: sinon.stub().resolves({ - _id: 'outbound-integration-id', - _environmentId: 'env-id', - _organizationId: 'org-id', - providerId: EmailProviderIdEnum.Resend, - channel: ChannelTypeEnum.EMAIL, - credentials: {}, - active: true, - }), - }; - const service = new ChatSdkService( - logger as any, - {} as any, - {} as any, - {} as any, - integrationRepository as any, - {} as any, - {} as any, - { create: sinon.stub().resolves({ _id: 'm' }) } as any - ); - const sendEmail = (service as any).buildSendEmailCallback( - { - environmentId: 'env-id', - organizationId: 'org-id', - credentials: {}, - }, - 'outbound-integration-id' - ); - - const result = await sendEmail({ - from: 'agent@example.com', - to: 'user@gmail.com', - subject: 'Re: Hello', - text: '👀', - html: '

👀

', - alternatives: [ - { - contentType: 'text/vnd.google.email-reaction+json', - content: JSON.stringify({ version: 1, emoji: '👀' }), - }, - ], - messageId: '', - inReplyTo: '', - references: '', - }); - - expect(result).to.deep.equal({ messageId: '' }); - expect(logger.warn.calledOnce).to.equal(true); - expect(logger.warn.firstCall.args[0]).to.deep.equal({ - providerId: EmailProviderIdEnum.Resend, - outboundIntegrationId: 'outbound-integration-id', - }); - expect(logger.warn.firstCall.args[1]).to.include('does not support custom MIME alternatives'); - expect( - integrationRepository.findOne.calledOnceWithMatch({ - _id: 'outbound-integration-id', - channel: ChannelTypeEnum.EMAIL, - }) - ).to.equal(true); - }); - - it('should not claim success when unsupported MIME alternatives omit messageId', async () => { - const logger = { - warn: sinon.stub(), - error: sinon.stub(), - debug: sinon.stub(), - info: sinon.stub(), - setContext: sinon.stub(), - }; - const integrationRepository = { - findOne: sinon.stub().resolves({ - _id: 'outbound-integration-id', - _environmentId: 'env-id', - _organizationId: 'org-id', - providerId: EmailProviderIdEnum.Resend, - channel: ChannelTypeEnum.EMAIL, - credentials: {}, - active: true, - }), - }; - const service = new ChatSdkService( - logger as any, - {} as any, - {} as any, - {} as any, - integrationRepository as any, - {} as any, - {} as any, - { create: sinon.stub().resolves({ _id: 'm' }) } as any - ); - const sendEmail = (service as any).buildSendEmailCallback( - { - environmentId: 'env-id', - organizationId: 'org-id', - credentials: {}, - }, - 'outbound-integration-id' - ); - - const result = await sendEmail({ - from: 'agent@example.com', - to: 'user@gmail.com', - subject: 'Re: Hello', - text: '👀', - html: '

👀

', - alternatives: [ - { - contentType: 'text/vnd.google.email-reaction+json', - content: JSON.stringify({ version: 1, emoji: '👀' }), - }, - ], - }); - - expect(result).to.deep.equal({ messageId: undefined }); - expect(logger.warn.calledOnce).to.equal(true); - expect(logger.warn.firstCall.args[0]).to.deep.equal({ - providerId: EmailProviderIdEnum.Resend, - outboundIntegrationId: 'outbound-integration-id', - }); - expect(logger.warn.firstCall.args[1]).to.include('no messageId was supplied'); - }); - }); - - describe('sendViaNovuDemoProvider', () => { - let novuEnterprise: string | undefined; - let isSelfHosted: string | undefined; - let sharedInboundDomain: string | undefined; - let novuEmailApiKey: string | undefined; - - beforeEach(() => { - novuEnterprise = process.env.NOVU_ENTERPRISE; - isSelfHosted = process.env.IS_SELF_HOSTED; - sharedInboundDomain = process.env.NOVU_AGENT_SHARED_INBOUND_DOMAIN; - novuEmailApiKey = process.env.NOVU_EMAIL_INTEGRATION_API_KEY; - - process.env.NOVU_ENTERPRISE = 'true'; - delete process.env.IS_SELF_HOSTED; - process.env.NOVU_AGENT_SHARED_INBOUND_DOMAIN = 'agentconnect.sh'; - process.env.NOVU_EMAIL_INTEGRATION_API_KEY = 'test-key'; - }); - - afterEach(() => { - if (novuEnterprise === undefined) delete process.env.NOVU_ENTERPRISE; - else process.env.NOVU_ENTERPRISE = novuEnterprise; - - if (isSelfHosted === undefined) delete process.env.IS_SELF_HOSTED; - else process.env.IS_SELF_HOSTED = isSelfHosted; - - if (sharedInboundDomain === undefined) delete process.env.NOVU_AGENT_SHARED_INBOUND_DOMAIN; - else process.env.NOVU_AGENT_SHARED_INBOUND_DOMAIN = sharedInboundDomain; - - if (novuEmailApiKey === undefined) delete process.env.NOVU_EMAIL_INTEGRATION_API_KEY; - else process.env.NOVU_EMAIL_INTEGRATION_API_KEY = novuEmailApiKey; - - sinon.restore(); - }); - - function makeSendViaService(deps: { - calculateLimit?: { execute: sinon.SinonStub }; - messageCreate?: sinon.SinonStub; - }) { - const logger = { - warn: sinon.stub(), - error: sinon.stub(), - debug: sinon.stub(), - info: sinon.stub(), - setContext: sinon.stub(), - }; - - const calculateLimitNovuIntegration = deps.calculateLimit ?? { - execute: sinon.stub().resolves({ limit: 300, count: 0 }), - }; - - const messageRepository = { - create: deps.messageCreate ?? sinon.stub().resolves({ _id: 'msg-1' }), - }; - - return new ChatSdkService( - logger as any, - {} as any, - {} as any, - {} as any, - {} as any, - {} as any, - calculateLimitNovuIntegration as any, - messageRepository as any - ); - } - - const demoConfig = { - environmentId: 'env-id', - organizationId: 'org-id', - agentId: 'agent-1', - credentials: { - emailSlugPrefix: 'support', - inboxRoutingKey: 'abcd1234', - senderName: 'Support', - }, - }; - - const demoParams = { - from: 'fallback@example.com', - to: 'user@example.com', - subject: 'Hello', - html: '

Hi

', - messageId: '', - }; - - const demoIntegration = { - _id: 'novu-demo-id', - _environmentId: 'env-id', - _organizationId: 'org-id', - providerId: EmailProviderIdEnum.Novu, - channel: ChannelTypeEnum.EMAIL, - name: 'Novu Email', - identifier: 'novu-email-demo', - active: true, - primary: true, - credentials: {}, - }; - - it('throws when Novu demo email credentials are not configured', async () => { - delete process.env.NOVU_EMAIL_INTEGRATION_API_KEY; - const messageCreate = sinon.stub().resolves({ _id: 'msg-1' }); - const service = makeSendViaService({ messageCreate }); - - try { - await (service as any).sendViaNovuDemoProvider(demoConfig, demoParams, demoIntegration); - throw new Error('Expected sendViaNovuDemoProvider to throw'); - } catch (err) { - expect(err).to.be.instanceOf(BadRequestException); - expect((err as BadRequestException).message).to.include('not configured on this deployment'); - expect(messageCreate.called).to.equal(false); - } - }); - - it('persists a Message with providerId Novu after successful send for quota accounting', async () => { - const messageCreate = sinon.stub().resolves({ _id: 'msg-1' }); - const service = makeSendViaService({ messageCreate }); - const sendStub = sinon.stub().resolves({ id: 'sg-123' }); - sinon.stub(MailFactory.prototype, 'getHandler').returns({ send: sendStub }); - - const result = await (service as any).sendViaNovuDemoProvider(demoConfig, demoParams, demoIntegration); - - expect(result).to.deep.equal({ messageId: 'sg-123' }); - expect(sendStub.calledOnce).to.equal(true); - expect(messageCreate.calledOnce).to.equal(true); - expect(messageCreate.firstCall.args[0]).to.include({ - _environmentId: 'env-id', - _organizationId: 'org-id', - channel: ChannelTypeEnum.EMAIL, - providerId: EmailProviderIdEnum.Novu, - email: 'user@example.com', - subject: 'Hello', - transactionId: 'sg-123', - }); - expect(messageCreate.firstCall.args[0].tags).to.deep.equal(['agent-demo-reply']); - }); - - it('throws when the Novu demo quota is exhausted', async () => { - const calculateLimit = { - execute: sinon.stub().resolves({ limit: 300, count: 300 }), - }; - const messageCreate = sinon.stub().resolves({ _id: 'msg-1' }); - const service = makeSendViaService({ calculateLimit, messageCreate }); - - try { - await (service as any).sendViaNovuDemoProvider(demoConfig, demoParams, demoIntegration); - throw new Error('Expected sendViaNovuDemoProvider to throw'); - } catch (err) { - expect(err).to.be.instanceOf(BadRequestException); - expect((err as BadRequestException).message).to.include('quota exhausted'); - expect(messageCreate.called).to.equal(false); - } - }); - }); -}); diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts deleted file mode 100644 index 2f89a78aec8..00000000000 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ /dev/null @@ -1,1552 +0,0 @@ -import { randomUUID } from 'node:crypto'; -import * as dns from 'node:dns'; -import * as http from 'node:http'; -import * as https from 'node:https'; -import { BadGatewayException, BadRequestException, Injectable, OnModuleDestroy } from '@nestjs/common'; -import { - areNovuEmailCredentialsSet, - assertSafeOutboundUrl, - buildAgentSharedInbox, - CacheService, - CalculateLimitNovuIntegration, - decryptCredentials, - isAgentSharedInboxEnabled, - isPrivateIp, - MailFactory, - PinoLogger, - SsrfBlockedError, -} from '@novu/application-generic'; -import { IntegrationEntity, IntegrationRepository, MessageRepository } from '@novu/dal'; -import type { AgentAction, SentMessageInfo } from '@novu/framework'; -import { ChannelTypeEnum, EmailProviderIdEnum, type IEmailOptions } from '@novu/shared'; -import type { AdapterPostableMessage, Chat, EmojiValue, Message, PlanModel, ReactionEvent, Thread } from 'chat'; -import { Request as ExpressRequest, Response as ExpressResponse } from 'express'; -import { LRUCache } from 'lru-cache'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; -import type { FileRef, ReplyContentDto } from '../dtos/agent-reply-payload.dto'; -import { captureAgentException, captureAgentWarning } from '../utils/capture-agent-sentry'; -import { esmImport } from '../utils/esm-import'; -import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request'; -import { AgentConfigResolver, AgentConfigResolveSource, ResolvedAgentConfig } from './agent-config-resolver.service'; -import { AgentEmailActionClaims, AgentEmailActionTokenService } from './agent-email-action-token.service'; -import type { InboundReactionEvent } from './agent-inbound-handler.service'; - -export interface InboundCallbacks { - onMessage: (agentId: string, config: ResolvedAgentConfig, thread: Thread, message: Message) => Promise; - onAction: ( - agentId: string, - config: ResolvedAgentConfig, - thread: Thread, - action: AgentAction, - userId: string - ) => Promise; - onReaction: (agentId: string, config: ResolvedAgentConfig, event: InboundReactionEvent) => Promise; -} - -function getErrorResponseBody(err: unknown): unknown { - if (!err || typeof err !== 'object') { - return undefined; - } - - return (err as { response?: { body?: unknown } }).response?.body; -} - -function getDeliveryErrorDetail(body: unknown): string | undefined { - if (!body || typeof body !== 'object') { - return undefined; - } - - const responseBody = body as { errors?: Array<{ message?: unknown }>; message?: unknown }; - const firstErrorMessage = responseBody.errors?.[0]?.message; - if (typeof firstErrorMessage === 'string') { - return firstErrorMessage; - } - - return typeof responseBody.message === 'string' ? responseBody.message : undefined; -} - -function toDeliveryError(err: unknown): never { - const base = err instanceof Error ? err.message : String(err); - const detail = getDeliveryErrorDetail(getErrorResponseBody(err)); - - throw new BadGatewayException({ - error: 'delivery_failed', - message: detail ? `${base}: ${detail}` : base, - }); -} - -/** Ensure a Message-ID value is wrapped in RFC 5322 angle brackets. */ -function wrapMsgId(id: string): string { - const trimmed = id.trim(); - - return trimmed.startsWith('<') && trimmed.endsWith('>') ? trimmed : `<${trimmed}>`; -} - -function resolveAgentEmailSenderName(config: ResolvedAgentConfig): string { - return config.credentials.senderName?.trim() || config.agentName; -} - -/** - * Thrown by `ChatSdkService.processEmailAction` when a failure is provably pre-dispatch — - * i.e. token validation, agent-config lookup, or chat/adapter setup failed before the chat - * SDK had a chance to invoke the agent's `onAction` handler. Callers can safely retry these - * via single-use token release. Any other error (including raw exceptions out of - * `chat.processAction`) MUST be treated as potentially post-dispatch and not replayed. - */ -export class AgentActionPreDispatchError extends Error { - readonly preDispatch = true as const; - - constructor(message: string, cause?: unknown) { - super(message); - this.name = 'AgentActionPreDispatchError'; - if (cause !== undefined) { - (this as { cause?: unknown }).cause = cause; - } - } -} - -/** - * Extracts the recipient email address from an encoded email thread ID. The email adapter's - * ThreadResolver encodes thread IDs as `email::`; we - * reverse that here so the token claims can carry the recipient as the `platformUserId` used - * for subscriber resolution on the click handler side. - */ -function extractRecipientFromThreadId(threadId: string): string { - const parts = threadId.split(':'); - if (parts.length !== 3 || parts[0] !== 'email' || !parts[1]) { - throw new Error(`Cannot extract recipient from invalid email thread id: ${threadId}`); - } - - return decodeURIComponent(parts[1]); -} - -/** - * ICredentials field mapping per platform adapter: - * - * Slack: credentials.signingSecret → signingSecret - * connection.auth.accessToken → botToken - * - * Teams: credentials.clientId → appId - * credentials.secretKey → appPassword - * credentials.tenantId → appTenantId - * - * WhatsApp: credentials.apiToken → accessToken - * credentials.secretKey → appSecret - * credentials.token → verifyToken - * credentials.phoneNumberIdentification → phoneNumberId - */ - -const MAX_CACHED_INSTANCES = 200; -const INSTANCE_TTL_MS = 1000 * 60 * 30; -const BASE64_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; -const MAX_INLINE_FILE_BYTES = 5 * 1024 * 1024; -const MAX_INLINE_AGGREGATE_FILE_BYTES = 5 * 1024 * 1024; -const MAX_FILE_BYTES = 25 * 1024 * 1024; -const MAX_FILES_PER_MESSAGE = 15; -const MAX_AGGREGATE_FILE_BYTES = 50 * 1024 * 1024; -const MAX_INLINE_FILE_BASE64_CHARS = 7_000_000; -const FILE_FETCH_TIMEOUT_MS = 10_000; -const MAX_FILE_FETCH_REDIRECTS = 3; -const SUPPORTED_FILE_PLATFORMS = new Set([ - AgentPlatformEnum.SLACK, - AgentPlatformEnum.TEAMS, - AgentPlatformEnum.WHATSAPP, -]); -const UNSUPPORTED_FILE_PLATFORMS = new Set([AgentPlatformEnum.EMAIL]); -// EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS is a deliberate allowlist for providers that preserve custom MIME -// alternatives used by Gmail reactions; Braze, Brevo, Mailgun, Mailjet, Mailtrap, Mandrill, Plunk, Postmark, -// Resend, SparkPost, and similar providers are excluded until their SDK paths are verified. -const EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS = new Set([ - EmailProviderIdEnum.CustomSMTP, - EmailProviderIdEnum.Outlook365, - EmailProviderIdEnum.SendGrid, - EmailProviderIdEnum.SES, -]); - -/** - * Holds a cached Chat instance alongside a mutable pointer to the current - * resolved config. Event handlers registered via registerEventHandlers() close - * over this box instead of the config value, so updates to fields that the - * bridge executor and inbound handler read at event time (bridgeUrl, - * devBridgeUrl, devBridgeActive, acknowledgeOnReceived, reactionOnResolved) take - * effect on the next inbound event without rebuilding the Chat instance. - * - * adapterFingerprint captures fields that are baked into the platform adapter - * at construction (credentials + connectionAccessToken); when these change, - * the cached instance is dropped and rebuilt — see getOrCreate(). - */ -interface CachedChat { - chat: Chat; - config: ResolvedAgentConfig; - adapterFingerprint: string; -} - -type ChatSdkFile = Omit & { data?: Buffer }; -type ChatSdkReplyContent = Omit & { files?: ChatSdkFile[] }; -type MaterializedFile = ChatSdkFile & { size: number; source: 'data' | 'url' }; -type PinnedFileResponse = { - status: number; - statusText: string; - headers: http.IncomingHttpHeaders; - data: Buffer; -}; - -@Injectable() -export class ChatSdkService implements OnModuleDestroy { - private readonly instances: LRUCache; - private readonly pendingCreations = new Map>(); - private inboundCallbacks: InboundCallbacks | null = null; - - constructor( - private readonly logger: PinoLogger, - private readonly cacheService: CacheService, - private readonly agentConfigResolver: AgentConfigResolver, - private readonly integrationRepository: IntegrationRepository, - private readonly actionTokenService: AgentEmailActionTokenService, - private readonly calculateLimitNovuIntegration: CalculateLimitNovuIntegration, - private readonly messageRepository: MessageRepository - ) { - this.logger.setContext(this.constructor.name); - this.instances = new LRUCache({ - max: MAX_CACHED_INSTANCES, - ttl: INSTANCE_TTL_MS, - dispose: (cached, key) => { - cached.chat.shutdown().catch((err) => { - this.logger.error(err, `Failed to shut down evicted Chat instance ${key}`); - captureAgentException(err, { - component: 'chat-sdk', - operation: 'shutdown-evicted', - extra: { instanceKey: key }, - }); - }); - }, - }); - } - - async handleWebhook( - agentId: string, - integrationIdentifier: string, - req: ExpressRequest, - res: ExpressResponse, - options: { source: AgentConfigResolveSource } - ) { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier, { - source: options.source, - }); - const { platform } = config; - const instanceKey = `${agentId}:${integrationIdentifier}`; - - const chat = await this.getOrCreate(instanceKey, agentId, platform, config); - const handler = chat.webhooks[platform]; - if (!handler) { - throw new BadRequestException(`Platform ${platform} not configured for agent ${agentId}`); - } - - const webRequest = toWebRequest(req); - const webResponse = await handler(webRequest); - - await sendWebResponse(webResponse, res); - } - - /** - * Dispatches a verified email-button click into the chat SDK so it flows through the same - * `chat.onAction` → `AgentInboundHandler.handleAction` → bridge `onAction` path that - * inbound platforms (Slack/Teams) already use. Called from the public email-action endpoint - * after token verification and single-use replay protection. - * - * The implementation is split into a *pre-dispatch* phase (config resolution, chat-instance - * lookup, adapter availability check) and a *dispatch* phase (`chat.processAction`). Errors - * raised by the pre-dispatch phase are wrapped in `AgentActionPreDispatchError` so the - * controller can safely release the single-use token and let the user retry. Errors raised - * by the dispatch phase propagate as-is — by then the chat SDK may have already invoked the - * agent's `onAction` handler with partial side effects, and re-releasing the token would - * permit a replay that duplicates non-idempotent downstream work. - */ - async processEmailAction(claims: AgentEmailActionClaims): Promise { - const { agentId, integrationIdentifier } = claims; - - let chat: Chat; - let emailAdapter: ReturnType; - try { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - - if (config.platform !== AgentPlatformEnum.EMAIL) { - throw new BadRequestException( - `Agent ${agentId} integration ${integrationIdentifier} is not configured for the email platform` - ); - } - - const instanceKey = `${agentId}:${integrationIdentifier}`; - chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - emailAdapter = chat.getAdapter(AgentPlatformEnum.EMAIL); - if (!emailAdapter) { - throw new BadRequestException(`Email adapter not available for agent ${agentId}`); - } - } catch (err) { - throw new AgentActionPreDispatchError('Failed to resolve agent context before dispatching email action', err); - } - - // From here on, the chat SDK may have already invoked the user's `onAction` handler by - // the time an error is raised — do NOT retry these failures via token re-release. - await chat.processAction( - { - adapter: emailAdapter, - actionId: claims.actionId, - value: claims.value, - messageId: claims.messageId, - threadId: claims.threadId, - user: { - userId: claims.userIdentifier, - userName: claims.userIdentifier, - fullName: claims.userIdentifier, - isBot: false, - isMe: false, - }, - raw: {}, - }, - undefined - ); - } - - async onModuleDestroy() { - const shutdowns = [...this.instances.entries()].map(async ([key, cached]) => { - try { - await cached.chat.shutdown(); - } catch (err) { - this.logger.error(err, `Failed to shut down Chat instance ${key}`); - captureAgentException(err, { component: 'chat-sdk', operation: 'shutdown', extra: { instanceKey: key } }); - } - }); - - await Promise.allSettled(shutdowns); - this.instances.clear(); - } - - registerInboundCallbacks(callbacks: InboundCallbacks): void { - this.inboundCallbacks = callbacks; - } - - async postToConversation( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - content: ReplyContentDto - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - // `chat.thread()` (chat@4.27+) infers the adapter from the threadId prefix and - // returns a Thread already wired to this Chat instance's state adapter, so we - // avoid rehydrating from a serialized blob and don't trip the "No Chat singleton - // registered" check that `ThreadImpl.fromJSON` hits for card/postable replies. - const thread = chat.thread(platformThreadId); - const deliveryContent = await this.prepareContentForDelivery(content, platform, agentId); - - const postArg = this.buildAdapterPostableMessage(deliveryContent); - - const sent = await thread.post(postArg).catch(toDeliveryError); - - return { messageId: sent.id, platformThreadId: sent.threadId }; - } - - async startTypingInConversation( - agentId: string, - integrationIdentifier: string, - platformThreadId: string, - status = 'Thinking...' - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - const thread = chat.thread(platformThreadId); - - if (typeof thread.startTyping !== 'function') { - return; - } - - await thread.startTyping(status).catch(toDeliveryError); - } - - async sendDirectMessage( - agentId: string, - integrationIdentifier: string, - platformUserId: string, - content: ReplyContentDto - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const dmThread = await chat.openDM(platformUserId); - const deliveryContent = await this.prepareContentForDelivery(content, config.platform, agentId); - - const postArg = this.buildAdapterPostableMessage(deliveryContent); - - const sent = await dmThread.post(postArg).catch(toDeliveryError); - - // Slack Assistant Threads return a threadId like "slack:D12345:" — append the - // root message ts so it matches the format getInboundPlatformThreadId produces - // when the user replies, keeping inbound and outbound on the same conversation. - const platformThreadId = sent.threadId.endsWith(':') ? `${sent.threadId}${sent.id}` : sent.threadId; - - return { messageId: sent.id, platformThreadId }; - } - - async editInConversation( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - platformMessageId: string, - content: ReplyContentDto - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const adapter = chat.getAdapter(platform); - if (typeof adapter.editMessage !== 'function') { - throw new BadRequestException(`Platform ${platform} does not support editing messages`); - } - - const deliveryContent = await this.prepareContentForDelivery(content, platform, agentId); - - const editPayload = this.buildAdapterPostableMessage(deliveryContent); - - let editPromise: Promise<{ id: string; threadId: string }>; - if (deliveryContent.card) { - editPromise = adapter.editMessage( - platformThreadId, - platformMessageId, - deliveryContent.card as unknown as AdapterPostableMessage - ); - } else { - editPromise = adapter.editMessage(platformThreadId, platformMessageId, editPayload); - } - - const edited = await editPromise.catch(toDeliveryError); - - return { messageId: edited.id, platformThreadId: edited.threadId }; - } - - async postPlanObject( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - model: PlanModel - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const adapter = chat.getAdapter(platform); - if (typeof adapter.postObject !== 'function') { - return null; - } - - const sent = await adapter.postObject(platformThreadId, 'plan', model).catch(toDeliveryError); - - return { messageId: sent.id, platformThreadId: sent.threadId }; - } - - async editPlanObject( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - platformMessageId: string, - model: PlanModel - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const adapter = chat.getAdapter(platform); - if (typeof adapter.editObject !== 'function') { - return; - } - - await adapter.editObject(platformThreadId, platformMessageId, 'plan', model).catch(toDeliveryError); - } - - private async prepareContentForDelivery( - content: ReplyContentDto, - platform: string = AgentPlatformEnum.SLACK, - agentId?: string - ): Promise { - if (!content.files?.length) { - return content as ChatSdkReplyContent; - } - - if (UNSUPPORTED_FILE_PLATFORMS.has(platform)) { - this.logger.warn( - { - agentId, - platform, - droppedCount: content.files.length, - }, - 'Dropping outbound agent files because platform does not support attachments' - ); - - const { files: _files, ...withoutFiles } = content; - - return withoutFiles as ChatSdkReplyContent; - } - - if (!SUPPORTED_FILE_PLATFORMS.has(platform)) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `File attachments are not supported on platform "${platform}".`, - }); - } - - if (content.files.length > MAX_FILES_PER_MESSAGE) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Too many attachments: maximum is ${MAX_FILES_PER_MESSAGE} files per message.`, - }); - } - - const files: ChatSdkFile[] = []; - let aggregateSize = 0; - let inlineAggregateSize = 0; - - for (const [index, file] of content.files.entries()) { - const materialized = await this.prepareFileForDelivery(file, index); - aggregateSize += materialized.size; - if (materialized.source === 'data') { - inlineAggregateSize += materialized.size; - } - - if (aggregateSize > MAX_AGGREGATE_FILE_BYTES) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Total attachment size exceeds ${this.formatBytes(MAX_AGGREGATE_FILE_BYTES)}.`, - }); - } - - if (inlineAggregateSize > MAX_INLINE_AGGREGATE_FILE_BYTES) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Total inline attachment size exceeds ${this.formatBytes(MAX_INLINE_AGGREGATE_FILE_BYTES)}. Use URLs for larger files.`, - }); - } - - const { size: _size, source: _source, ...chatSdkFile } = materialized; - files.push(chatSdkFile); - } - - return { - ...content, - files, - }; - } - - private buildAdapterPostableMessage(deliveryContent: ChatSdkReplyContent): AdapterPostableMessage { - if (deliveryContent.card) { - const payload: { card: unknown; files?: ChatSdkFile[] } = { - card: deliveryContent.card, - }; - - if (deliveryContent.files?.length) { - payload.files = deliveryContent.files; - } - - return payload as unknown as AdapterPostableMessage; - } - - return { - markdown: deliveryContent.markdown ?? '', - files: deliveryContent.files, - } as unknown as AdapterPostableMessage; - } - - private async prepareFileForDelivery(file: FileRef, index: number): Promise { - const data = (file as { data?: unknown }).data; - const url = (file as { url?: unknown }).url; - - if (data !== undefined && data !== null) { - if (typeof data !== 'string') { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, - }); - } - - const buffer = this.decodeBase64FileData(data, file, index); - const { url: _url, ...fileWithoutUrl } = file; - - return { - ...fileWithoutUrl, - data: buffer, - size: buffer.length, - source: 'data', - }; - } - - if (typeof url !== 'string') { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: provide a public HTTP(S) url or base64 data.`, - }); - } - - const fetched = await this.fetchFileUrl(url, file, index); - const { url: _url, ...fileWithoutUrl } = file; - - return { - ...fileWithoutUrl, - data: fetched.data, - mimeType: file.mimeType || fetched.mimeType, - size: fetched.data.length, - source: 'url', - }; - } - - private decodeBase64FileData(data: string, file: FileRef, index: number): Buffer { - const normalized = data.replace(/\s/g, ''); - const remainder = normalized.length % 4; - - if (normalized.length > MAX_INLINE_FILE_BASE64_CHARS) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, - }); - } - - if (!normalized || remainder === 1 || !BASE64_REGEX.test(normalized)) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, - }); - } - - const padded = remainder === 0 ? normalized : normalized.padEnd(normalized.length + (4 - remainder), '='); - const buffer = Buffer.from(padded, 'base64'); - - if (buffer.toString('base64').replace(/=+$/, '') !== normalized.replace(/=+$/, '')) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: data must be a base64-encoded string.`, - }); - } - - if (buffer.length > MAX_INLINE_FILE_BYTES) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: inline data must be ${this.formatBytes(MAX_INLINE_FILE_BYTES)} or smaller.`, - }); - } - - return buffer; - } - - private async fetchFileUrl(url: string, file: FileRef, index: number): Promise<{ data: Buffer; mimeType?: string }> { - const response = await this.fetchValidatedFileUrl(url, file, index); - - if (response.status < 200 || response.status >= 300) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Failed to fetch file ${this.describeFile(file, index)}: ${response.status} ${response.statusText}`, - }); - } - - const contentLength = this.getHeader(response.headers, 'content-length'); - if (contentLength) { - const size = Number(contentLength); - if (Number.isFinite(size) && size > MAX_FILE_BYTES) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, - }); - } - } - - const data = response.data; - const mimeType = this.getHeader(response.headers, 'content-type'); - - return { data, mimeType }; - } - - private async fetchValidatedFileUrl(url: string, file: FileRef, index: number): Promise { - let currentUrl = url; - - for (let redirectCount = 0; redirectCount <= MAX_FILE_FETCH_REDIRECTS; redirectCount += 1) { - const ssrfError = await this.validateFileUrl(currentUrl); - if (ssrfError) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)} url: ${ssrfError}`, - }); - } - - let response: PinnedFileResponse; - try { - response = await this.requestPinnedFileUrl(currentUrl, file, index); - } catch (err) { - if (err instanceof BadRequestException) { - throw err; - } - - const message = err instanceof Error ? err.message : String(err); - throw new BadRequestException({ - error: 'attachment_failed', - message: `Failed to fetch file ${this.describeFile(file, index)}: ${message}`, - }); - } - - if (response.status < 300 || response.status >= 400) { - return response; - } - - const location = this.getHeader(response.headers, 'location'); - if (!location) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Failed to fetch file ${this.describeFile(file, index)}: redirect response missing Location header.`, - }); - } - - currentUrl = new URL(location, currentUrl).toString(); - } - - throw new BadRequestException({ - error: 'attachment_failed', - message: `Failed to fetch file ${this.describeFile(file, index)}: too many redirects.`, - }); - } - - private async validateFileUrl(url: string): Promise { - try { - assertSafeOutboundUrl(url); - } catch (err) { - if (err instanceof SsrfBlockedError) { - return err.message; - } - throw err; - } - - return null; - } - - private async requestPinnedFileUrl(url: string, file: FileRef, index: number): Promise { - const parsed = new URL(url); - const address = await this.resolvePublicAddress(parsed, file, index); - const client = parsed.protocol === 'https:' ? https : http; - - return await new Promise((resolve, reject) => { - const request = client.request( - { - protocol: parsed.protocol, - hostname: address.address, - family: address.family, - port: parsed.port || undefined, - path: `${parsed.pathname}${parsed.search}`, - method: 'GET', - headers: { Host: parsed.host }, - servername: parsed.hostname, - timeout: FILE_FETCH_TIMEOUT_MS, - }, - (response) => { - const status = response.statusCode ?? 0; - const statusText = response.statusMessage ?? ''; - - if (status >= 300 && status < 400) { - response.resume(); - resolve({ status, statusText, headers: response.headers, data: Buffer.alloc(0) }); - - return; - } - - const contentLength = this.getHeader(response.headers, 'content-length'); - if (contentLength) { - const size = Number(contentLength); - if (Number.isFinite(size) && size > MAX_FILE_BYTES) { - response.destroy(); - reject( - new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, - }) - ); - - return; - } - } - - const chunks: Buffer[] = []; - let total = 0; - - response.on('data', (chunk: Buffer) => { - total += chunk.length; - if (total > MAX_FILE_BYTES) { - response.destroy( - new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)}: file size exceeds ${this.formatBytes(MAX_FILE_BYTES)}.`, - }) - ); - - return; - } - - chunks.push(chunk); - }); - response.on('end', () => - resolve({ status, statusText, headers: response.headers, data: Buffer.concat(chunks, total) }) - ); - response.on('error', reject); - } - ); - - request.on('timeout', () => request.destroy(new Error('Request timed out'))); - request.on('error', reject); - request.end(); - }); - } - - private async resolvePublicAddress(parsed: URL, file: FileRef, index: number): Promise { - let addresses: dns.LookupAddress[]; - try { - addresses = await dns.promises.lookup(parsed.hostname, { all: true }); - } catch { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, - }); - } - - if (!addresses.length) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)} url: Unable to resolve hostname "${parsed.hostname}".`, - }); - } - - for (const { address } of addresses) { - if (isPrivateIp(address)) { - throw new BadRequestException({ - error: 'attachment_failed', - message: `Invalid file ${this.describeFile(file, index)} url: Requests to private or reserved IP addresses are not allowed (resolved: ${address}).`, - }); - } - } - - return addresses[0]; - } - - private getHeader(headers: http.IncomingHttpHeaders, name: string): string | undefined { - const value = headers[name.toLowerCase()]; - - return Array.isArray(value) ? value[0] : value; - } - - private describeFile(file: FileRef, index: number): string { - return file.filename ? `"${file.filename}"` : `at index ${index}`; - } - - private formatBytes(bytes: number): string { - return `${Math.floor(bytes / (1024 * 1024))} MB`; - } - - async removeReaction( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - platformMessageId: string, - emojiName: string - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const adapter = chat.getAdapter(platform); - const resolved = await this.resolveEmoji(emojiName); - await adapter.removeReaction(platformThreadId, platformMessageId, resolved); - } - - async reactToMessage( - agentId: string, - integrationIdentifier: string, - platform: string, - platformThreadId: string, - platformMessageId: string, - emojiName: string - ): Promise { - const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); - const instanceKey = `${agentId}:${integrationIdentifier}`; - const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); - - const adapter = chat.getAdapter(platform); - const resolved = await this.resolveEmoji(emojiName); - await adapter.addReaction(platformThreadId, platformMessageId, resolved); - } - - private async resolveEmoji(name: string): Promise { - const { getEmoji } = await esmImport('chat'); - const resolved = getEmoji(name); - if (!resolved) { - throw new Error(`Unknown emoji name: "${name}". Use GET /agents/emoji to list supported options.`); - } - - return resolved; - } - - private async getOrCreate( - instanceKey: string, - agentId: string, - platform: AgentPlatformEnum, - config: ResolvedAgentConfig - ): Promise { - const freshFingerprint = this.adapterFingerprint(config); - const existing = this.instances.get(instanceKey); - - if (existing) { - if (existing.adapterFingerprint === freshFingerprint) { - existing.config = config; - - return existing.chat; - } - - // Credentials / connection token changed since this instance was built — - // the platform adapter is frozen with the old values, so we must rebuild. - // Delete triggers the LRU dispose hook which calls chat.shutdown(). - this.instances.delete(instanceKey); - } - - // Key pending builds by (instanceKey + fingerprint) so that a build kicked - // off with stale credentials can't be observed by a later caller that has - // already-rotated credentials — that caller would otherwise await the - // in-flight promise and receive a Chat whose adapter is baked with the old - // secrets. With this keying, concurrent callers with divergent configs - // each get their own build; the later instances.set() wins and the LRU - // dispose hook shuts down the superseded Chat. - const pendingKey = `${instanceKey}:${freshFingerprint}`; - const pending = this.pendingCreations.get(pendingKey); - if (pending) return pending; - - const creation = this.createAndCache(instanceKey, agentId, platform, config, freshFingerprint); - this.pendingCreations.set(pendingKey, creation); - - try { - return await creation; - } finally { - this.pendingCreations.delete(pendingKey); - } - } - - private async createAndCache( - instanceKey: string, - agentId: string, - platform: AgentPlatformEnum, - config: ResolvedAgentConfig, - adapterFingerprint: string - ): Promise { - const chat = await this.createChatInstance(instanceKey, agentId, platform, config); - await chat.initialize(); - const cached: CachedChat = { chat, config, adapterFingerprint }; - this.registerEventHandlers(agentId, cached); - this.instances.set(instanceKey, cached); - - return chat; - } - - /** - * Fingerprint of every field baked into the Chat instance at construction - * time — i.e. everything read by buildAdapters() and createChatInstance(). - * When the fingerprint changes, the cached instance must be rebuilt because - * these values live inside already-constructed platform adapters and cannot - * be mutated after the fact. - * - * JSON.stringify over a fixed-shape object is injective (JSON escapes rule - * out delimiter collisions across free-form secret values), which is all we - * need for an equality-based cache-coherence check. We deliberately do NOT - * hash: this is not credential verification or password storage, so fast - * hashing would be architecturally wrong and the plaintext is already - * retained in cached.config for the entry's lifetime anyway. - * - * IMPORTANT: keep in sync with buildAdapters() whenever a new adapter input - * is added. Missing a field here will cause the cache to silently serve - * stale credentials until the LRU TTL expires. - */ - private adapterFingerprint(config: ResolvedAgentConfig): string { - const { platform, credentials: c, connectionAccessToken } = config; - - return JSON.stringify({ - platform, - signingSecret: c.signingSecret ?? null, - clientId: c.clientId ?? null, - secretKey: c.secretKey ?? null, - tenantId: c.tenantId ?? null, - apiToken: c.apiToken ?? null, - token: c.token ?? null, - phoneNumberIdentification: c.phoneNumberIdentification ?? null, - connectionAccessToken: connectionAccessToken ?? null, - outboundIntegrationId: c.outboundIntegrationId ?? null, - useFromAddressOverride: c.useFromAddressOverride ?? null, - fromAddressOverride: c.fromAddressOverride ?? null, - // Email-specific fields closed over by the sendEmail callback (demo path): - // a slug rename, routing-key rotation, shared-inbox toggle, or sender - // rebrand must rebuild the cached adapter otherwise the agent keeps - // replying from the stale From/Reply-To address until the LRU TTL - // expires. - emailSlugPrefix: c.emailSlugPrefix ?? null, - inboxRoutingKey: c.inboxRoutingKey ?? null, - sharedInboxDisabled: c.sharedInboxDisabled ?? null, - senderName: c.senderName ?? null, - agentName: config.agentName, - }); - } - - private buildSendEmailCallback( - config: ResolvedAgentConfig, - outboundIntegrationId: string | undefined - ): (params: { - from: string; - to: string; - subject: string; - html: string; - text?: string; - alternatives?: Array<{ - contentType: string; - content: string | Buffer; - }>; - inReplyTo?: string; - references?: string; - messageId?: string; - }) => Promise<{ messageId?: string }> { - return async (params) => { - if (!outboundIntegrationId) { - throw new BadRequestException( - 'Email agent integration is missing outboundIntegrationId. Reconfigure the agent email setup.' - ); - } - - const integration = await this.integrationRepository.findOne({ - _id: outboundIntegrationId, - _environmentId: config.environmentId, - _organizationId: config.organizationId, - channel: ChannelTypeEnum.EMAIL, - }); - - if (!integration) { - throw new BadRequestException( - `Outbound email integration ${outboundIntegrationId} not found or does not belong to this environment` - ); - } - - if (integration.providerId === EmailProviderIdEnum.NovuAgent) { - throw new BadRequestException( - `Integration ${outboundIntegrationId} is the inbound NovuAgent provider and cannot be used as an outbound sender` - ); - } - - if (!integration.active) { - throw new BadRequestException( - `Outbound email integration ${outboundIntegrationId} (${integration.providerId}) is inactive` - ); - } - - if (integration.providerId === EmailProviderIdEnum.Novu) { - return this.sendViaNovuDemoProvider(config, params, integration); - } - - const hasUnsupportedAlternatives = - params.alternatives?.length && !EMAIL_ALTERNATIVES_SUPPORTED_PROVIDERS.has(integration.providerId); - if (hasUnsupportedAlternatives) { - // NovuEmailAdapterImpl.addReaction supplies a reaction Message-ID; any custom MIME alternative caller must do - // the same so skipped unsupported sends don't claim provider delivery. - if (!params.messageId) { - this.logger.warn( - { - providerId: integration.providerId, - outboundIntegrationId, - }, - 'Skipping email with custom MIME alternatives because the outbound provider is unsupported and no messageId was supplied' - ); - - return { messageId: undefined }; - } - - this.logger.warn( - { - providerId: integration.providerId, - outboundIntegrationId, - }, - 'Skipping email reaction because the outbound provider does not support custom MIME alternatives' - ); - - return { messageId: params.messageId }; - } - - const decrypted = decryptCredentials(integration.credentials); - - // The chat-adapter-email contract guarantees params.from is the agent's inbound address - // (see packages/chat-adapter-email/src/adapter.ts postMessage/addReaction). We treat it as - // the Reply-To target so subscriber replies still reach the agent's inbox even when the - // outbound From is rewritten to the sending provider's configured sender (or a per-agent - // override). When neither override nor outbound.from is set, we fall back to the agent - // address for From and skip Reply-To — preserving the legacy behavior. - // Prefer the shared-inbox address (cloud) so Reply-To always routes back to the agent - // even when the SDK has no DomainRoute-derived address handy. - const agentInboundAddress = this.resolveAgentInboundAddress(config, params.from); - const overrideFrom = config.credentials.useFromAddressOverride - ? config.credentials.fromAddressOverride?.trim() || undefined - : undefined; - const outboundFrom = (decrypted.from as string | undefined)?.trim() || undefined; - const effectiveFrom = overrideFrom || agentInboundAddress || outboundFrom; - const replyToHeader = effectiveFrom !== agentInboundAddress ? agentInboundAddress : undefined; - const senderName = resolveAgentEmailSenderName(config); - - const mailFactory = new MailFactory(); - const handler = mailFactory.getHandler({ ...integration, credentials: decrypted }, effectiveFrom); - - const mailOptions: IEmailOptions = { - to: [params.to], - subject: params.subject, - html: params.html, - text: params.text, - alternatives: params.alternatives, - from: effectiveFrom, - ...(replyToHeader ? { replyTo: replyToHeader } : {}), - senderName, - headers: { - ...(params.messageId ? { 'Message-ID': wrapMsgId(params.messageId) } : {}), - ...(params.inReplyTo ? { 'In-Reply-To': wrapMsgId(params.inReplyTo) } : {}), - ...(params.references - ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } - : {}), - }, - }; - - const result = await handler.send(mailOptions).catch(toDeliveryError); - - return { messageId: result?.id || params.messageId || '' }; - }; - } - - /** - * Resolve the canonical inbound address used for Reply-To. Preference order: - * - * 1. The synthetic shared inbox `{slug}-{inboxRoutingKey}@` - * when the cloud feature is enabled and the shared inbox itself is not - * disabled. System-managed and always works. - * 2. The fallback supplied by the chat-adapter-email SDK — already a - * custom-domain agent route configured by the user (the SDK builds it - * from `DomainRoute` rows), or whatever the platform passed on - * self-hosted. - * - * Replies must always reach an inbox the worker will actually process, so - * we deliberately do not return the shared inbox here when - * `sharedInboxDisabled` is set — the worker would drop those messages and - * we fall through to the SDK's custom-domain address instead. - */ - private resolveAgentInboundAddress(config: ResolvedAgentConfig, fallback: string): string { - const slug = config.credentials.emailSlugPrefix; - const inboxRoutingKey = config.credentials.inboxRoutingKey; - const sharedDisabled = Boolean(config.credentials.sharedInboxDisabled); - if (isAgentSharedInboxEnabled() && slug && inboxRoutingKey && !sharedDisabled) { - try { - return buildAgentSharedInbox(slug, inboxRoutingKey); - } catch (err) { - this.logger.warn({ err, agentId: config.agentId }, 'Falling back to params.from - shared inbox build failed'); - captureAgentWarning(err, { - component: 'chat-sdk', - operation: 'resolve-agent-inbound-address', - agentId: config.agentId, - }); - } - } - - return fallback; - } - - /** - * Outbound demo path: the agent is wired to the bundled Novu Email demo - * provider row. We override the integration's stored credentials with the - * cloud demo API key (`NOVU_EMAIL_INTEGRATION_API_KEY`) and force `From` to - * `{slug}-{inboxRoutingKey}@` so replies route back to the - * same inbox. Quota-gated by the same per-environment 300/month cap as - * workflow notification emails. - * - * Refuses to send when the shared inbox prerequisites aren't met because - * the demo path can't recover a Reply-To without it — at that point the - * user must attach a real outbound email provider. - */ - private async sendViaNovuDemoProvider( - config: ResolvedAgentConfig, - params: { - from: string; - to: string; - subject: string; - html: string; - text?: string; - alternatives?: Array<{ contentType: string; content: string | Buffer }>; - inReplyTo?: string; - references?: string; - messageId?: string; - }, - integration: IntegrationEntity - ): Promise<{ messageId?: string }> { - if (!isAgentSharedInboxEnabled() || !config.credentials.emailSlugPrefix || !config.credentials.inboxRoutingKey) { - throw new BadRequestException( - 'Email agent integration requires either a shared agent inbox or a custom outbound email provider. ' + - 'Configure one in the agent email setup.' - ); - } - - if (config.credentials.sharedInboxDisabled) { - throw new BadRequestException( - 'The Novu demo sender requires the shared inbox to be enabled. ' + - 'Re-enable it or attach an outbound email provider.' - ); - } - - const limit = await this.calculateLimitNovuIntegration.execute({ - channelType: ChannelTypeEnum.EMAIL, - environmentId: config.environmentId, - organizationId: config.organizationId, - }); - if (limit && limit.count >= limit.limit) { - throw new BadRequestException( - `Novu demo email quota exhausted for this environment (${limit.count}/${limit.limit} this month). Attach an outbound email provider (e.g. SendGrid) to remove this cap.` - ); - } - - if (!areNovuEmailCredentialsSet()) { - throw new BadRequestException( - 'Novu demo email is not configured on this deployment. Attach an outbound email provider to send replies.' - ); - } - - const from = buildAgentSharedInbox(config.credentials.emailSlugPrefix, config.credentials.inboxRoutingKey); - const senderName = resolveAgentEmailSenderName(config); - - // The Novu demo integration row's stored credentials are empty by design — - // the real API key lives in the deployment's env so a single Novu-managed - // SendGrid account fans out across every org's demo sender. We rebuild - // credentials on every send rather than mutating the row. - const demoIntegration: IntegrationEntity = { - ...integration, - credentials: { - apiKey: process.env.NOVU_EMAIL_INTEGRATION_API_KEY, - from, - senderName, - ipPoolName: 'Demo', - }, - }; - - const mailFactory = new MailFactory(); - const handler = mailFactory.getHandler(demoIntegration, from); - - const mailOptions: IEmailOptions = { - to: [params.to], - subject: params.subject, - html: params.html, - text: params.text, - alternatives: params.alternatives, - from, - senderName, - headers: { - ...(params.messageId ? { 'Message-ID': wrapMsgId(params.messageId) } : {}), - ...(params.inReplyTo ? { 'In-Reply-To': wrapMsgId(params.inReplyTo) } : {}), - ...(params.references - ? { References: params.references.split(/\s+/).filter(Boolean).map(wrapMsgId).join(' ') } - : {}), - }, - }; - - const result = await handler.send(mailOptions).catch(toDeliveryError); - - const messageIdForReturn = result?.id || params.messageId || ''; - - try { - await this.messageRepository.create({ - _environmentId: config.environmentId, - _organizationId: config.organizationId, - channel: ChannelTypeEnum.EMAIL, - providerId: EmailProviderIdEnum.Novu, - email: params.to, - subject: params.subject, - transactionId: messageIdForReturn || randomUUID(), - payload: { - agentId: config.agentId, - html: params.html, - text: params.text, - }, - tags: ['agent-demo-reply'], - }); - } catch (err) { - this.logger.warn( - { err, environmentId: config.environmentId, agentId: config.agentId }, - 'Failed to persist Novu demo email message for quota accounting' - ); - captureAgentWarning(err, { - component: 'chat-sdk', - operation: 'persist-demo-email-quota', - agentId: config.agentId, - extra: { environmentId: config.environmentId }, - }); - } - - return { messageId: messageIdForReturn }; - } - - private async createChatInstance( - instanceKey: string, - agentId: string, - platform: AgentPlatformEnum, - config: ResolvedAgentConfig - ): Promise { - const [{ Chat }, { createIoRedisState }] = await Promise.all([ - esmImport('chat'), - esmImport('@chat-adapter/state-ioredis'), - ]); - - const adapters = await this.buildAdapters(agentId, platform, config); - const client = this.cacheService.client; - if (!client) { - throw new Error('Cache in-memory provider client is not available for Conversational SDK state adapter'); - } - - return new Chat({ - userName: `novu-agent-${instanceKey}`, - adapters, - state: createIoRedisState({ - client, - keyPrefix: `novu:agent:${instanceKey}`, - logger: this.chatStateLogger(), - }), - logger: 'silent', - }); - } - - private chatStateLogger() { - return { - debug: (msg: string, ctx?: Record) => this.logger.debug(ctx ?? {}, msg), - info: (msg: string, ctx?: Record) => this.logger.info(ctx ?? {}, msg), - warn: (msg: string, ctx?: Record) => { - this.logger.warn(ctx ?? {}, msg); - if (ctx?.err) { - captureAgentWarning(ctx.err, { - component: 'chat-sdk', - operation: 'chat-state-warn', - extra: { message: msg }, - }); - } - }, - error: (msg: string, ctx?: Record) => { - this.logger.error(ctx ?? {}, msg); - if (ctx?.err) { - captureAgentException(ctx.err, { - component: 'chat-sdk', - operation: 'chat-state-error', - extra: { message: msg }, - }); - } - }, - }; - } - - private async buildAdapters( - agentId: string, - platform: AgentPlatformEnum, - config: ResolvedAgentConfig - ): Promise> { - const { credentials, connectionAccessToken } = config; - - switch (platform) { - case AgentPlatformEnum.SLACK: { - if (!connectionAccessToken || !credentials.signingSecret) { - throw new BadRequestException('Slack agent integration requires botToken and signingSecret credentials'); - } - - const { createSlackAdapter } = await esmImport('@chat-adapter/slack'); - - return { - slack: createSlackAdapter({ - botToken: connectionAccessToken, - signingSecret: credentials.signingSecret, - }), - }; - } - case AgentPlatformEnum.TEAMS: { - if (!credentials.clientId || !credentials.secretKey || !credentials.tenantId) { - throw new BadRequestException( - 'Teams agent integration requires appId, appPassword, and appTenantId credentials' - ); - } - - const { createTeamsAdapter } = await esmImport('@chat-adapter/teams'); - - return { - teams: createTeamsAdapter({ - appId: credentials.clientId, - appPassword: credentials.secretKey, - appTenantId: credentials.tenantId, - }), - }; - } - case AgentPlatformEnum.WHATSAPP: { - if ( - !credentials.apiToken || - !credentials.secretKey || - !credentials.token || - !credentials.phoneNumberIdentification - ) { - throw new BadRequestException( - 'WhatsApp agent integration requires accessToken, appSecret, verifyToken, and phoneNumberId credentials' - ); - } - - const { createWhatsAppAdapter } = await esmImport('@chat-adapter/whatsapp'); - - return { - whatsapp: createWhatsAppAdapter({ - accessToken: credentials.apiToken, - appSecret: credentials.secretKey, - verifyToken: credentials.token, - phoneNumberId: credentials.phoneNumberIdentification, - }), - }; - } - case AgentPlatformEnum.TELEGRAM: { - if (!credentials.apiToken || !credentials.token) { - throw new BadRequestException( - 'Telegram agent integration requires a Bot Token and a webhook secret token. ' + - 'Run the "Configure webhook" step to provision the webhook secret token before this integration can receive messages.' - ); - } - - const { createTelegramAdapter } = await esmImport('@chat-adapter/telegram'); - - return { - telegram: createTelegramAdapter({ - botToken: credentials.apiToken, - secretToken: credentials.token, - mode: 'webhook', - }), - }; - } - case AgentPlatformEnum.EMAIL: { - const { outboundIntegrationId } = credentials; - - if (!credentials.secretKey) { - throw new BadRequestException('Email agent integration requires secretKey credentials'); - } - - const { createNovuEmailAdapter } = await esmImport('@novu/chat-adapter-email'); - - return { - email: createNovuEmailAdapter({ - senderName: resolveAgentEmailSenderName(config), - signingSecret: credentials.secretKey, - sendEmail: this.buildSendEmailCallback(config, outboundIntegrationId), - actionUrlBuilder: async ({ threadId, messageId, actionId, value, label, style }) => { - const userIdentifier = extractRecipientFromThreadId(threadId); - const { url } = await this.actionTokenService.signActionToken({ - agentId, - agentIdentifier: config.agentIdentifier, - agentName: config.agentName, - integrationIdentifier: config.integrationIdentifier, - environmentId: config.environmentId, - organizationId: config.organizationId, - threadId, - messageId, - actionId, - value, - label, - style, - userIdentifier, - }); - - return url; - }, - }), - }; - } - default: - throw new BadRequestException(`Unsupported platform: ${platform}`); - } - } - - private registerEventHandlers(agentId: string, cached: CachedChat) { - if (!this.inboundCallbacks) { - this.logger.warn(`[agent:${agentId}] No inbound callbacks registered, skipping event handler setup`); - - return; - } - - const callbacks = this.inboundCallbacks; - - cached.chat.onNewMention(async (thread: Thread, message: Message) => { - try { - await thread.subscribe(); - await callbacks.onMessage(agentId, cached.config, thread, message); - } catch (err) { - this.logger.error(err, `[agent:${agentId}] Error handling new mention`); - captureAgentException(err, { component: 'chat-sdk', operation: 'on-new-mention', agentId }); - } - }); - - cached.chat.onSubscribedMessage(async (thread: Thread, message: Message) => { - try { - await callbacks.onMessage(agentId, cached.config, thread, message); - } catch (err) { - this.logger.error(err, `[agent:${agentId}] Error handling subscribed message`); - captureAgentException(err, { component: 'chat-sdk', operation: 'on-subscribed-message', agentId }); - } - }); - - cached.chat.onAction(async (event) => { - try { - if (!event.thread) { - this.logger.warn(`[agent:${agentId}] Action received without thread context, skipping`); - - return; - } - - await callbacks.onAction( - agentId, - cached.config, - event.thread as Thread, - { - id: event.actionId, - value: event.value, - sourceMessageId: event.messageId, - }, - event.user.userId - ); - } catch (err) { - this.logger.error(err, `[agent:${agentId}] Error handling action ${event.actionId}`); - captureAgentException(err, { - component: 'chat-sdk', - operation: 'on-action', - agentId, - extra: { actionId: event.actionId }, - }); - } - }); - - cached.chat.onReaction(async (event: ReactionEvent) => { - try { - await callbacks.onReaction(agentId, cached.config, { - emoji: event.emoji, - added: event.added, - messageId: event.messageId, - message: event.message, - thread: event.thread as Thread | undefined, - user: event.user, - }); - } catch (err) { - this.logger.error(err, `[agent:${agentId}] Error handling reaction`); - captureAgentException(err, { component: 'chat-sdk', operation: 'on-reaction', agentId }); - } - }); - } -} diff --git a/apps/api/src/app/agents/guards/agent-conversation-enabled.guard.ts b/apps/api/src/app/agents/shared/agent-conversation-enabled.guard.ts similarity index 100% rename from apps/api/src/app/agents/guards/agent-conversation-enabled.guard.ts rename to apps/api/src/app/agents/shared/agent-conversation-enabled.guard.ts diff --git a/apps/api/src/app/agents/filters/agent-runtime-exception.filter.ts b/apps/api/src/app/agents/shared/agent-runtime-exception.filter.ts similarity index 97% rename from apps/api/src/app/agents/filters/agent-runtime-exception.filter.ts rename to apps/api/src/app/agents/shared/agent-runtime-exception.filter.ts index 919b309c3a3..db72709164a 100644 --- a/apps/api/src/app/agents/filters/agent-runtime-exception.filter.ts +++ b/apps/api/src/app/agents/shared/agent-runtime-exception.filter.ts @@ -13,7 +13,7 @@ import { PinoLogger, } from '@novu/application-generic'; import type { Response } from 'express'; -import { captureAgentException } from '../utils/capture-agent-sentry'; +import { captureAgentException } from './errors/capture-agent-sentry'; function httpStatusFromError(err: AgentRuntimeError): number { if (err instanceof AgentRuntimeUnauthorizedError) return HttpStatus.UNAUTHORIZED; diff --git a/apps/api/src/app/agents/agent-analytics.ts b/apps/api/src/app/agents/shared/analytics/agent-analytics.ts similarity index 100% rename from apps/api/src/app/agents/agent-analytics.ts rename to apps/api/src/app/agents/shared/analytics/agent-analytics.ts diff --git a/apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts b/apps/api/src/app/agents/shared/dtos/add-agent-integration-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/add-agent-integration-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/add-agent-integration-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-behavior.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-behavior.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-behavior.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-behavior.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-inbox.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-inbox.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-inbox.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-inbox.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-integration-response.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-integration-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-integration-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-integration-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-integration-summary.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-integration-summary.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-integration-summary.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-integration-summary.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-reply-payload.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-reply-payload.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-response.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/agent-runtime-config.dto.ts b/apps/api/src/app/agents/shared/dtos/agent-runtime-config.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-runtime-config.dto.ts rename to apps/api/src/app/agents/shared/dtos/agent-runtime-config.dto.ts diff --git a/apps/api/src/app/agents/dtos/configure-telegram-webhook-response.dto.ts b/apps/api/src/app/agents/shared/dtos/configure-telegram-webhook-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/configure-telegram-webhook-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/configure-telegram-webhook-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/configure-whatsapp-webhook-response.dto.ts b/apps/api/src/app/agents/shared/dtos/configure-whatsapp-webhook-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/configure-whatsapp-webhook-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/configure-whatsapp-webhook-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/consume-telegram-mobile-link.dto.ts b/apps/api/src/app/agents/shared/dtos/consume-telegram-mobile-link.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/consume-telegram-mobile-link.dto.ts rename to apps/api/src/app/agents/shared/dtos/consume-telegram-mobile-link.dto.ts diff --git a/apps/api/src/app/agents/dtos/create-agent-request.dto.ts b/apps/api/src/app/agents/shared/dtos/create-agent-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/create-agent-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/create-agent-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/cursor-pagination-query.dto.ts b/apps/api/src/app/agents/shared/dtos/cursor-pagination-query.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/cursor-pagination-query.dto.ts rename to apps/api/src/app/agents/shared/dtos/cursor-pagination-query.dto.ts diff --git a/apps/api/src/app/agents/dtos/generate-managed-agent.dto.ts b/apps/api/src/app/agents/shared/dtos/generate-managed-agent.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/generate-managed-agent.dto.ts rename to apps/api/src/app/agents/shared/dtos/generate-managed-agent.dto.ts diff --git a/apps/api/src/app/agents/dtos/index.ts b/apps/api/src/app/agents/shared/dtos/index.ts similarity index 100% rename from apps/api/src/app/agents/dtos/index.ts rename to apps/api/src/app/agents/shared/dtos/index.ts diff --git a/apps/api/src/app/agents/dtos/issue-telegram-mobile-link-request.dto.ts b/apps/api/src/app/agents/shared/dtos/issue-telegram-mobile-link-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/issue-telegram-mobile-link-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/issue-telegram-mobile-link-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/issue-telegram-mobile-link-response.dto.ts b/apps/api/src/app/agents/shared/dtos/issue-telegram-mobile-link-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/issue-telegram-mobile-link-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/issue-telegram-mobile-link-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/issue-telegram-subscriber-link-request.dto.ts b/apps/api/src/app/agents/shared/dtos/issue-telegram-subscriber-link-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/issue-telegram-subscriber-link-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/issue-telegram-subscriber-link-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/issue-telegram-subscriber-link-response.dto.ts b/apps/api/src/app/agents/shared/dtos/issue-telegram-subscriber-link-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/issue-telegram-subscriber-link-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/issue-telegram-subscriber-link-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/list-agent-integrations-query.dto.ts b/apps/api/src/app/agents/shared/dtos/list-agent-integrations-query.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/list-agent-integrations-query.dto.ts rename to apps/api/src/app/agents/shared/dtos/list-agent-integrations-query.dto.ts diff --git a/apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts b/apps/api/src/app/agents/shared/dtos/list-agent-integrations-response.dto.ts similarity index 73% rename from apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/list-agent-integrations-response.dto.ts index 75d651eb4b9..f423ce44655 100644 --- a/apps/api/src/app/agents/dtos/list-agent-integrations-response.dto.ts +++ b/apps/api/src/app/agents/shared/dtos/list-agent-integrations-response.dto.ts @@ -1,4 +1,4 @@ -import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; +import { withCursorPagination } from '../../../shared/dtos/cursor-paginated-response'; import { AgentIntegrationResponseDto } from './agent-integration-response.dto'; export class ListAgentIntegrationsResponseDto extends withCursorPagination(AgentIntegrationResponseDto, { diff --git a/apps/api/src/app/agents/dtos/list-agents-query.dto.ts b/apps/api/src/app/agents/shared/dtos/list-agents-query.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/list-agents-query.dto.ts rename to apps/api/src/app/agents/shared/dtos/list-agents-query.dto.ts diff --git a/apps/api/src/app/agents/dtos/list-agents-response.dto.ts b/apps/api/src/app/agents/shared/dtos/list-agents-response.dto.ts similarity index 68% rename from apps/api/src/app/agents/dtos/list-agents-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/list-agents-response.dto.ts index ac130e7a153..6b2e240240d 100644 --- a/apps/api/src/app/agents/dtos/list-agents-response.dto.ts +++ b/apps/api/src/app/agents/shared/dtos/list-agents-response.dto.ts @@ -1,4 +1,4 @@ -import { withCursorPagination } from '../../shared/dtos/cursor-paginated-response'; +import { withCursorPagination } from '../../../shared/dtos/cursor-paginated-response'; import { AgentResponseDto } from './agent-response.dto'; export class ListAgentsResponseDto extends withCursorPagination(AgentResponseDto, { diff --git a/apps/api/src/app/agents/dtos/mcp-server.dto.ts b/apps/api/src/app/agents/shared/dtos/mcp-server.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/mcp-server.dto.ts rename to apps/api/src/app/agents/shared/dtos/mcp-server.dto.ts diff --git a/apps/api/src/app/agents/dtos/migrate-agent-runtime.dto.ts b/apps/api/src/app/agents/shared/dtos/migrate-agent-runtime.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/migrate-agent-runtime.dto.ts rename to apps/api/src/app/agents/shared/dtos/migrate-agent-runtime.dto.ts diff --git a/apps/api/src/app/agents/dtos/send-agent-test-email-request.dto.ts b/apps/api/src/app/agents/shared/dtos/send-agent-test-email-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/send-agent-test-email-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/send-agent-test-email-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/send-agent-welcome-message-request.dto.ts b/apps/api/src/app/agents/shared/dtos/send-agent-welcome-message-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/send-agent-welcome-message-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/send-agent-welcome-message-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/send-whatsapp-test-template.dto.ts b/apps/api/src/app/agents/shared/dtos/send-whatsapp-test-template.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/send-whatsapp-test-template.dto.ts rename to apps/api/src/app/agents/shared/dtos/send-whatsapp-test-template.dto.ts diff --git a/apps/api/src/app/agents/dtos/telegram-mobile-link-status-response.dto.ts b/apps/api/src/app/agents/shared/dtos/telegram-mobile-link-status-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/telegram-mobile-link-status-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/telegram-mobile-link-status-response.dto.ts diff --git a/apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts b/apps/api/src/app/agents/shared/dtos/update-agent-bridge-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/update-agent-bridge-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/update-agent-bridge-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/update-agent-integration-request.dto.ts b/apps/api/src/app/agents/shared/dtos/update-agent-integration-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/update-agent-integration-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/update-agent-integration-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/update-agent-request.dto.ts b/apps/api/src/app/agents/shared/dtos/update-agent-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/update-agent-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/update-agent-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/upload-custom-skill.dto.ts b/apps/api/src/app/agents/shared/dtos/upload-custom-skill.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/upload-custom-skill.dto.ts rename to apps/api/src/app/agents/shared/dtos/upload-custom-skill.dto.ts diff --git a/apps/api/src/app/agents/dtos/verify-managed-credentials-request.dto.ts b/apps/api/src/app/agents/shared/dtos/verify-managed-credentials-request.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/verify-managed-credentials-request.dto.ts rename to apps/api/src/app/agents/shared/dtos/verify-managed-credentials-request.dto.ts diff --git a/apps/api/src/app/agents/dtos/verify-managed-credentials-response.dto.ts b/apps/api/src/app/agents/shared/dtos/verify-managed-credentials-response.dto.ts similarity index 100% rename from apps/api/src/app/agents/dtos/verify-managed-credentials-response.dto.ts rename to apps/api/src/app/agents/shared/dtos/verify-managed-credentials-response.dto.ts diff --git a/apps/api/src/app/agents/usecases/list-agent-emoji/list-agent-emoji.usecase.ts b/apps/api/src/app/agents/shared/emoji/list-agent-emoji/list-agent-emoji.usecase.ts similarity index 93% rename from apps/api/src/app/agents/usecases/list-agent-emoji/list-agent-emoji.usecase.ts rename to apps/api/src/app/agents/shared/emoji/list-agent-emoji/list-agent-emoji.usecase.ts index 6691f44182c..62734171683 100644 --- a/apps/api/src/app/agents/usecases/list-agent-emoji/list-agent-emoji.usecase.ts +++ b/apps/api/src/app/agents/shared/emoji/list-agent-emoji/list-agent-emoji.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import type { EmojiFormats } from 'chat'; -import { esmImport } from '../../utils/esm-import'; +import { esmImport } from '../../util/esm-import'; export interface AgentEmojiEntry { name: string; diff --git a/apps/api/src/app/agents/dtos/agent-event.enum.ts b/apps/api/src/app/agents/shared/enums/agent-event.enum.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-event.enum.ts rename to apps/api/src/app/agents/shared/enums/agent-event.enum.ts diff --git a/apps/api/src/app/agents/dtos/agent-platform.enum.ts b/apps/api/src/app/agents/shared/enums/agent-platform.enum.ts similarity index 100% rename from apps/api/src/app/agents/dtos/agent-platform.enum.ts rename to apps/api/src/app/agents/shared/enums/agent-platform.enum.ts diff --git a/apps/api/src/app/agents/exceptions/agent-inactive.exception.ts b/apps/api/src/app/agents/shared/errors/agent-inactive.exception.ts similarity index 100% rename from apps/api/src/app/agents/exceptions/agent-inactive.exception.ts rename to apps/api/src/app/agents/shared/errors/agent-inactive.exception.ts diff --git a/apps/api/src/app/agents/utils/capture-agent-sentry.ts b/apps/api/src/app/agents/shared/errors/capture-agent-sentry.ts similarity index 100% rename from apps/api/src/app/agents/utils/capture-agent-sentry.ts rename to apps/api/src/app/agents/shared/errors/capture-agent-sentry.ts diff --git a/apps/api/src/app/agents/shared/index.ts b/apps/api/src/app/agents/shared/index.ts new file mode 100644 index 00000000000..9498a3f9de2 --- /dev/null +++ b/apps/api/src/app/agents/shared/index.ts @@ -0,0 +1,2 @@ +export { AgentConversationEnabledGuard } from './agent-conversation-enabled.guard'; +export { AgentRuntimeExceptionFilter } from './agent-runtime-exception.filter'; diff --git a/apps/api/src/app/agents/mappers/agent-response.mapper.ts b/apps/api/src/app/agents/shared/mappers/agent-response.mapper.ts similarity index 100% rename from apps/api/src/app/agents/mappers/agent-response.mapper.ts rename to apps/api/src/app/agents/shared/mappers/agent-response.mapper.ts diff --git a/apps/api/src/app/agents/utils/email-normalization.spec.ts b/apps/api/src/app/agents/shared/util/email-normalization.spec.ts similarity index 100% rename from apps/api/src/app/agents/utils/email-normalization.spec.ts rename to apps/api/src/app/agents/shared/util/email-normalization.spec.ts diff --git a/apps/api/src/app/agents/utils/email-normalization.ts b/apps/api/src/app/agents/shared/util/email-normalization.ts similarity index 100% rename from apps/api/src/app/agents/utils/email-normalization.ts rename to apps/api/src/app/agents/shared/util/email-normalization.ts diff --git a/apps/api/src/app/agents/utils/esm-import.ts b/apps/api/src/app/agents/shared/util/esm-import.ts similarity index 100% rename from apps/api/src/app/agents/utils/esm-import.ts rename to apps/api/src/app/agents/shared/util/esm-import.ts diff --git a/apps/api/src/app/agents/utils/express-to-web-request.ts b/apps/api/src/app/agents/shared/util/express-to-web-request.ts similarity index 100% rename from apps/api/src/app/agents/utils/express-to-web-request.ts rename to apps/api/src/app/agents/shared/util/express-to-web-request.ts diff --git a/apps/api/src/app/agents/utils/phone-normalization.ts b/apps/api/src/app/agents/shared/util/phone-normalization.ts similarity index 100% rename from apps/api/src/app/agents/utils/phone-normalization.ts rename to apps/api/src/app/agents/shared/util/phone-normalization.ts diff --git a/apps/api/src/app/agents/utils/platform-endpoint-config.ts b/apps/api/src/app/agents/shared/util/platform-endpoint-config.ts similarity index 92% rename from apps/api/src/app/agents/utils/platform-endpoint-config.ts rename to apps/api/src/app/agents/shared/util/platform-endpoint-config.ts index 7815e403e42..0226b538aff 100644 --- a/apps/api/src/app/agents/utils/platform-endpoint-config.ts +++ b/apps/api/src/app/agents/shared/util/platform-endpoint-config.ts @@ -1,5 +1,5 @@ import { ChannelEndpointType, ENDPOINT_TYPES } from '@novu/shared'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; +import { AgentPlatformEnum } from '../enums/agent-platform.enum'; interface PlatformEndpointMapping { endpointType: ChannelEndpointType; diff --git a/apps/api/src/app/agents/utils/provider-to-platform.ts b/apps/api/src/app/agents/shared/util/provider-to-platform.ts similarity index 89% rename from apps/api/src/app/agents/utils/provider-to-platform.ts rename to apps/api/src/app/agents/shared/util/provider-to-platform.ts index 39d79193d8b..0bac0c7798e 100644 --- a/apps/api/src/app/agents/utils/provider-to-platform.ts +++ b/apps/api/src/app/agents/shared/util/provider-to-platform.ts @@ -1,5 +1,5 @@ import { ChatProviderIdEnum, EmailProviderIdEnum } from '@novu/shared'; -import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; +import { AgentPlatformEnum } from '../enums/agent-platform.enum'; const PROVIDER_TO_PLATFORM: Partial> = { [ChatProviderIdEnum.Slack]: AgentPlatformEnum.SLACK, diff --git a/apps/api/src/app/agents/validators/is-well-known-emoji.validator.ts b/apps/api/src/app/agents/shared/validators/is-well-known-emoji.validator.ts similarity index 96% rename from apps/api/src/app/agents/validators/is-well-known-emoji.validator.ts rename to apps/api/src/app/agents/shared/validators/is-well-known-emoji.validator.ts index a2a6b6eff3a..d0855d4e51d 100644 --- a/apps/api/src/app/agents/validators/is-well-known-emoji.validator.ts +++ b/apps/api/src/app/agents/shared/validators/is-well-known-emoji.validator.ts @@ -5,7 +5,7 @@ import { ValidatorConstraint, type ValidatorConstraintInterface, } from 'class-validator'; -import { esmImport } from '../utils/esm-import'; +import { esmImport } from '../util/esm-import'; let cachedNames: Set | null = null; diff --git a/apps/api/src/app/agents/usecases/index.ts b/apps/api/src/app/agents/usecases/index.ts index fe1cbedaa91..422b2c8c657 100644 --- a/apps/api/src/app/agents/usecases/index.ts +++ b/apps/api/src/app/agents/usecases/index.ts @@ -1,50 +1,47 @@ -import { AddAgentIntegration } from './add-agent-integration/add-agent-integration.usecase'; -import { CleanupNovuEmail } from './cleanup-novu-email/cleanup-novu-email.usecase'; -import { ConfigureTelegramAgentWebhook } from './configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; -import { ConfigureWhatsAppWebhook } from './configure-whatsapp-webhook/configure-whatsapp-webhook.usecase'; -import { ConsumeTelegramMobileLink } from './consume-telegram-mobile-link/consume-telegram-mobile-link.usecase'; -import { CreateAgent } from './create-agent/create-agent.usecase'; -import { DeleteAgent } from './delete-agent/delete-agent.usecase'; -import { DisableAgentMcpServer } from './disable-agent-mcp-server/disable-agent-mcp-server.usecase'; -import { EnableAgentMcpServer } from './enable-agent-mcp-server/enable-agent-mcp-server.usecase'; -import { FindOrCreateNovuEmail } from './find-or-create-novu-email/find-or-create-novu-email.usecase'; -import { GenerateManagedAgent } from './generate-managed-agent/generate-managed-agent.usecase'; -import { GenerateMcpOAuthUrl } from './generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; -import { GetAgent } from './get-agent/get-agent.usecase'; -import { GetAgentDemoQuota } from './get-agent-demo-quota/get-agent-demo-quota.usecase'; -import { GetAgentRuntimeConfig } from './get-agent-runtime-config/get-agent-runtime-config.usecase'; -import { GetMcpConnectionStatus } from './get-mcp-connection-status/get-mcp-connection-status.usecase'; -import { GetMcpNovuAppCredentials } from './get-mcp-novu-app-credentials/get-mcp-novu-app-credentials.usecase'; -import { GetTelegramMobileLinkStatus } from './get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase'; -import { HandleAgentReply } from './handle-agent-reply/handle-agent-reply.usecase'; -import { HandlePlanProgress } from './handle-plan-progress/handle-plan-progress.usecase'; -import { IssueTelegramMobileLink } from './issue-telegram-mobile-link/issue-telegram-mobile-link.usecase'; -import { IssueTelegramSubscriberLink } from './issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; -import { LinkTelegramChatToSubscriber } from './link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase'; -import { ListAgentEmoji } from './list-agent-emoji/list-agent-emoji.usecase'; -import { ListAgentIntegrations } from './list-agent-integrations/list-agent-integrations.usecase'; -import { ListAgentMcpServers } from './list-agent-mcp-servers/list-agent-mcp-servers.usecase'; -import { ListAgents } from './list-agents/list-agents.usecase'; -import { CompleteManagedAgentSetup } from './managed-agent-setup/complete-managed-agent-setup.usecase'; -import { HandleManagedAgentSetupInbound } from './managed-agent-setup/handle-managed-agent-setup-inbound.usecase'; -import { McpOAuthCallback } from './mcp-oauth-callback/mcp-oauth-callback.usecase'; -import { MigrateAgentRuntime } from './migrate-agent-runtime/migrate-agent-runtime.usecase'; -import { ProvisionManagedAgent } from './provision-managed-agent/provision-managed-agent.usecase'; -import { RemoveAgentIntegration } from './remove-agent-integration/remove-agent-integration.usecase'; -import { SendAgentTestEmail } from './send-agent-test-email/send-agent-test-email.usecase'; -import { SendAgentWelcomeMessage } from './send-agent-welcome-message/send-agent-welcome-message.usecase'; -import { SendWhatsAppTestTemplate } from './send-whatsapp-test-template/send-whatsapp-test-template.usecase'; -import { SetAgentMcpServers } from './set-agent-mcp-servers/set-agent-mcp-servers.usecase'; -import { SyncAgentMcpServers } from './sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; -import { SyncAgentToEnvironment } from './sync-agent-to-environment/sync-agent-to-environment.usecase'; -import { ConfirmToolApproval } from './tool-approval/confirm-tool-approval.usecase'; -import { HandlePendingToolApprovals } from './tool-approval/handle-pending-tool-approvals.usecase'; -import { UpdateAgent } from './update-agent/update-agent.usecase'; -import { UpdateAgentInboxShared } from './update-agent-inbox-shared/update-agent-inbox-shared.usecase'; -import { UpdateAgentIntegration } from './update-agent-integration/update-agent-integration.usecase'; -import { UpdateAgentRuntimeConfig } from './update-agent-runtime-config/update-agent-runtime-config.usecase'; -import { UploadCustomSkill } from './upload-custom-skill/upload-custom-skill.usecase'; -import { VerifyManagedCredentials } from './verify-managed-credentials/verify-managed-credentials.usecase'; +import { AddAgentIntegration } from '../channels/integrations/add-agent-integration/add-agent-integration.usecase'; +import { ListAgentIntegrations } from '../channels/integrations/list-agent-integrations/list-agent-integrations.usecase'; +import { RemoveAgentIntegration } from '../channels/integrations/remove-agent-integration/remove-agent-integration.usecase'; +import { UpdateAgentIntegration } from '../channels/integrations/update-agent-integration/update-agent-integration.usecase'; +import { ConfigureTelegramAgentWebhook } from '../channels/telegram/configure-telegram-agent-webhook/configure-telegram-agent-webhook.usecase'; +import { ConsumeTelegramMobileLink } from '../channels/telegram-linking/consume-telegram-mobile-link/consume-telegram-mobile-link.usecase'; +import { GetTelegramMobileLinkStatus } from '../channels/telegram-linking/get-telegram-mobile-link-status/get-telegram-mobile-link-status.usecase'; +import { IssueTelegramMobileLink } from '../channels/telegram-linking/issue-telegram-mobile-link/issue-telegram-mobile-link.usecase'; +import { IssueTelegramSubscriberLink } from '../channels/telegram-linking/issue-telegram-subscriber-link/issue-telegram-subscriber-link.usecase'; +import { LinkTelegramChatToSubscriber } from '../channels/telegram-linking/link-telegram-chat-to-subscriber/link-telegram-chat-to-subscriber.usecase'; +import { ConfigureWhatsAppWebhook } from '../channels/whatsapp/configure-whatsapp-webhook/configure-whatsapp-webhook.usecase'; +import { SendWhatsAppTestTemplate } from '../channels/whatsapp/send-whatsapp-test-template/send-whatsapp-test-template.usecase'; +import { HandleAgentReply } from '../conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase'; +import { HandlePlanProgress } from '../conversation-runtime/reply/handle-plan-progress/handle-plan-progress.usecase'; +import { SendAgentWelcomeMessage } from '../conversation-runtime/reply/send-agent-welcome-message/send-agent-welcome-message.usecase'; +import { SendAgentTestEmail } from '../email/send-agent-test-email/send-agent-test-email.usecase'; +import { CompleteManagedAgentSetup } from '../managed-runtime/setup/complete-managed-agent-setup.usecase'; +import { HandleManagedAgentSetupInbound } from '../managed-runtime/setup/handle-managed-agent-setup-inbound.usecase'; +import { ConfirmToolApproval } from '../managed-runtime/tool-approval/confirm-tool-approval.usecase'; +import { HandlePendingToolApprovals } from '../managed-runtime/tool-approval/handle-pending-tool-approvals.usecase'; +import { CreateAgent } from '../management/usecases/create-agent/create-agent.usecase'; +import { DeleteAgent } from '../management/usecases/delete-agent/delete-agent.usecase'; +import { GenerateManagedAgent } from '../management/usecases/generate-managed-agent/generate-managed-agent.usecase'; +import { GetAgent } from '../management/usecases/get-agent/get-agent.usecase'; +import { GetAgentDemoQuota } from '../management/usecases/get-agent-demo-quota/get-agent-demo-quota.usecase'; +import { GetAgentRuntimeConfig } from '../management/usecases/get-agent-runtime-config/get-agent-runtime-config.usecase'; +import { ListAgents } from '../management/usecases/list-agents/list-agents.usecase'; +import { MigrateAgentRuntime } from '../management/usecases/migrate-agent-runtime/migrate-agent-runtime.usecase'; +import { ProvisionManagedAgent } from '../management/usecases/provision-managed-agent/provision-managed-agent.usecase'; +import { SyncAgentToEnvironment } from '../management/usecases/sync-agent-to-environment/sync-agent-to-environment.usecase'; +import { UpdateAgent } from '../management/usecases/update-agent/update-agent.usecase'; +import { UpdateAgentInboxShared } from '../management/usecases/update-agent-inbox-shared/update-agent-inbox-shared.usecase'; +import { UpdateAgentRuntimeConfig } from '../management/usecases/update-agent-runtime-config/update-agent-runtime-config.usecase'; +import { UploadCustomSkill } from '../management/usecases/upload-custom-skill/upload-custom-skill.usecase'; +import { VerifyManagedCredentials } from '../management/usecases/verify-managed-credentials/verify-managed-credentials.usecase'; +import { GetMcpConnectionStatus } from '../mcp/connections/get-mcp-connection-status/get-mcp-connection-status.usecase'; +import { GenerateMcpOAuthUrl } from '../mcp/oauth/generate-mcp-oauth-url/generate-mcp-oauth-url.usecase'; +import { McpOAuthCallback } from '../mcp/oauth/mcp-oauth-callback/mcp-oauth-callback.usecase'; +import { DisableAgentMcpServer } from '../mcp/servers/disable-agent-mcp-server/disable-agent-mcp-server.usecase'; +import { EnableAgentMcpServer } from '../mcp/servers/enable-agent-mcp-server/enable-agent-mcp-server.usecase'; +import { ListAgentMcpServers } from '../mcp/servers/list-agent-mcp-servers/list-agent-mcp-servers.usecase'; +import { SetAgentMcpServers } from '../mcp/servers/set-agent-mcp-servers/set-agent-mcp-servers.usecase'; +import { SyncAgentMcpServers } from '../mcp/servers/sync-agent-mcp-servers/sync-agent-mcp-servers.usecase'; +import { ListAgentEmoji } from '../shared/emoji/list-agent-emoji/list-agent-emoji.usecase'; export { ConfigureTelegramAgentWebhook, @@ -69,9 +66,7 @@ export const USE_CASES = [ UploadCustomSkill, DeleteAgent, AddAgentIntegration, - CleanupNovuEmail, ConfigureWhatsAppWebhook, - FindOrCreateNovuEmail, GenerateManagedAgent, IssueTelegramMobileLink, IssueTelegramSubscriberLink, @@ -99,7 +94,6 @@ export const USE_CASES = [ CompleteManagedAgentSetup, McpOAuthCallback, GetMcpConnectionStatus, - GetMcpNovuAppCredentials, VerifyManagedCredentials, HandlePendingToolApprovals, ConfirmToolApproval, diff --git a/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-delete.adapter.ts b/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-delete.adapter.ts index f229653b6bd..cba32793c22 100644 --- a/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-delete.adapter.ts +++ b/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-delete.adapter.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { AgentEntity } from '@novu/dal'; -import { DeleteAgentCommand } from '../../../../agents/usecases/delete-agent/delete-agent.command'; -import { DeleteAgent } from '../../../../agents/usecases/delete-agent/delete-agent.usecase'; +import { DeleteAgentCommand } from '../../../../agents/management/usecases/delete-agent/delete-agent.command'; +import { DeleteAgent } from '../../../../agents/management/usecases/delete-agent/delete-agent.usecase'; import { ISyncContext } from '../../../types/sync.types'; import { IBaseDeleteService } from '../base/interfaces/base-delete.interface'; diff --git a/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-sync.adapter.ts b/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-sync.adapter.ts index 2b825975580..327c3ee1a53 100644 --- a/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-sync.adapter.ts +++ b/apps/api/src/app/environments-v2/usecases/sync-strategies/adapters/agent-sync.adapter.ts @@ -4,7 +4,7 @@ import { AgentEntity } from '@novu/dal'; import { SyncAgentToEnvironment, SyncAgentToEnvironmentCommand, -} from '../../../../agents/usecases/sync-agent-to-environment'; +} from '../../../../agents/management/usecases/sync-agent-to-environment'; import { ISyncContext } from '../../../types/sync.types'; import { IBaseSyncService } from '../base/interfaces/base-sync.interface'; diff --git a/apps/api/src/app/integrations/integrations.module.ts b/apps/api/src/app/integrations/integrations.module.ts index 23504e626ef..0037bead307 100644 --- a/apps/api/src/app/integrations/integrations.module.ts +++ b/apps/api/src/app/integrations/integrations.module.ts @@ -8,7 +8,7 @@ import { MsTeamsTokenService, } from '@novu/application-generic'; import { CommunityOrganizationRepository, CommunityUserRepository } from '@novu/dal'; -import { TelegramMobileLinkTokenService } from '../agents/services/telegram-mobile-link-token.service'; +import { TelegramMobileLinkTokenService } from '../agents/channels/telegram-linking/telegram-mobile-link-token.service'; import { AuthModule } from '../auth/auth.module'; import { ChannelConnectionsModule } from '../channel-connections/channel-connections.module'; import { ChannelEndpointsModule } from '../channel-endpoints/channel-endpoints.module'; diff --git a/apps/api/src/app/integrations/usecases/consume-integration-store-telegram-mobile-link/consume-integration-store-telegram-mobile-link.usecase.ts b/apps/api/src/app/integrations/usecases/consume-integration-store-telegram-mobile-link/consume-integration-store-telegram-mobile-link.usecase.ts index 135372d4c11..3d8c7029d78 100644 --- a/apps/api/src/app/integrations/usecases/consume-integration-store-telegram-mobile-link/consume-integration-store-telegram-mobile-link.usecase.ts +++ b/apps/api/src/app/integrations/usecases/consume-integration-store-telegram-mobile-link/consume-integration-store-telegram-mobile-link.usecase.ts @@ -7,7 +7,7 @@ import shortid from 'shortid'; import { InvalidTelegramMobileTokenError, TelegramMobileLinkTokenService, -} from '../../../agents/services/telegram-mobile-link-token.service'; +} from '../../../agents/channels/telegram-linking/telegram-mobile-link-token.service'; import { CreateIntegrationCommand } from '../create-integration/create-integration.command'; import { CreateIntegration } from '../create-integration/create-integration.usecase'; import { ConsumeIntegrationStoreTelegramMobileLinkCommand } from './consume-integration-store-telegram-mobile-link.command'; diff --git a/apps/api/src/app/integrations/usecases/get-integration-store-telegram-mobile-link-status/get-integration-store-telegram-mobile-link-status.usecase.ts b/apps/api/src/app/integrations/usecases/get-integration-store-telegram-mobile-link-status/get-integration-store-telegram-mobile-link-status.usecase.ts index 625ab25cc29..99091329478 100644 --- a/apps/api/src/app/integrations/usecases/get-integration-store-telegram-mobile-link-status/get-integration-store-telegram-mobile-link-status.usecase.ts +++ b/apps/api/src/app/integrations/usecases/get-integration-store-telegram-mobile-link-status/get-integration-store-telegram-mobile-link-status.usecase.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { InvalidTelegramMobileTokenError, TelegramMobileLinkTokenService, -} from '../../../agents/services/telegram-mobile-link-token.service'; +} from '../../../agents/channels/telegram-linking/telegram-mobile-link-token.service'; import { GetIntegrationStoreTelegramMobileLinkStatusCommand } from './get-integration-store-telegram-mobile-link-status.command'; export interface GetIntegrationStoreTelegramMobileLinkStatusResultValid { diff --git a/apps/api/src/app/integrations/usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.usecase.ts b/apps/api/src/app/integrations/usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.usecase.ts index 8784ac7b8d0..e15b466afbb 100644 --- a/apps/api/src/app/integrations/usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.usecase.ts +++ b/apps/api/src/app/integrations/usecases/issue-integration-store-telegram-mobile-link/issue-integration-store-telegram-mobile-link.usecase.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { TelegramMobileLinkTokenService } from '../../../agents/services/telegram-mobile-link-token.service'; +import { TelegramMobileLinkTokenService } from '../../../agents/channels/telegram-linking/telegram-mobile-link-token.service'; import { IssueIntegrationStoreTelegramMobileLinkCommand } from './issue-integration-store-telegram-mobile-link.command'; export interface IssueIntegrationStoreTelegramMobileLinkResult { diff --git a/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts b/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts index b344cb12293..4c28ec9dce6 100644 --- a/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts +++ b/apps/worker/src/app/workflow/usecases/run-job/run-job.usecase.ts @@ -169,6 +169,10 @@ export class RunJob { job.payload?.__source ); + nr.addCustomAttributes({ + workflow: workflow.name, + }); + const schedule = await this.getSubscriberSchedule.execute( GetSubscriberScheduleCommand.create({ environmentId: job._environmentId,