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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/api/src/app/agents/agents.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SharedModule } from '../shared/shared.module';
import { AgentsController } from './agents.controller';
import { AgentsWebhookController } from './agents-webhook.controller';
import { AgentConversationService } from './services/agent-conversation.service';
import { AgentCredentialService } from './services/agent-credential.service';
import { AgentConfigResolver } from './services/agent-config-resolver.service';
import { AgentInboundHandler } from './services/agent-inbound-handler.service';
import { AgentSubscriberResolver } from './services/agent-subscriber-resolver.service';
import { BridgeExecutorService } from './services/bridge-executor.service';
Expand All @@ -27,7 +27,7 @@ import { USE_CASES } from './usecases';
ChannelEndpointRepository,
ConversationRepository,
ConversationActivityRepository,
AgentCredentialService,
AgentConfigResolver,
AgentSubscriberResolver,
AgentConversationService,
AgentInboundHandler,
Expand Down
29 changes: 28 additions & 1 deletion apps/api/src/app/agents/dtos/agent-behavior.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsOptional } from 'class-validator';
import { IsBoolean, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';

export class AgentReactionSettingsDto {
@ApiPropertyOptional({
description: 'Emoji reaction for incoming messages. Emoji name string to customize, null to disable. Default: "eyes" (πŸ‘€)',
default: 'eyes',
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
onMessageReceived?: string | null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

@ApiPropertyOptional({
description: 'Emoji reaction when a conversation is resolved. Emoji name string to customize, null to disable. Default: "check" (βœ…)',
default: 'check',
})
@IsOptional()
@ValidateIf((_, value) => value !== null)
@IsString()
onResolved?: string | null;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

export class AgentBehaviorDto {
@ApiPropertyOptional({ description: 'Show a "Thinking..." indicator while the agent is processing a message' })
@IsBoolean()
@IsOptional()
thinkingIndicatorEnabled?: boolean;

@ApiPropertyOptional({ type: AgentReactionSettingsDto, description: 'Automatic emoji reactions on messages' })
@ValidateNested()
@Type(() => AgentReactionSettingsDto)
@IsOptional()
reactions?: AgentReactionSettingsDto;
}
2 changes: 2 additions & 0 deletions apps/api/src/app/agents/e2e/agent-reply.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {

const chatSdkService = testServer.getService(ChatSdkService);
sinon.stub(chatSdkService, 'postToConversation').resolves();
sinon.stub(chatSdkService, 'reactToMessage').resolves();
sinon.stub(chatSdkService, 'removeReaction').resolves();
});

function postReply(body: Record<string, unknown>) {
Expand Down
18 changes: 14 additions & 4 deletions apps/api/src/app/agents/e2e/agent-webhook.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { expect } from 'chai';
import sinon from 'sinon';
import { AgentInboundHandler } from '../services/agent-inbound-handler.service';
import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service';
import { AgentCredentialService } from '../services/agent-credential.service';
import { AgentConfigResolver } from '../services/agent-config-resolver.service';
import { AgentEventEnum } from '../dtos/agent-event.enum';
import {
setupAgentTestContext,
Expand All @@ -20,6 +20,15 @@ import {
} from './helpers/agent-test-setup';
import { signSlackRequest, buildSlackChallenge } from './helpers/providers/slack';

function mockSentMessage() {
return {
addReaction: async () => {},
removeReaction: async () => {},
edit: async () => mockSentMessage(),
delete: async () => {},
};
}

function mockThread(id: string, channelId = 'C_TEST') {
return {
id,
Expand All @@ -28,6 +37,7 @@ function mockThread(id: string, channelId = 'C_TEST') {
startTyping: async () => {},
subscribe: async () => {},
toJSON: () => ({ id, platform: 'slack', channelId, serialized: true }),
createSentMessageFromMessage: () => mockSentMessage(),
};
}

Expand All @@ -48,7 +58,7 @@ function mockMessage(opts: { id?: string; userId: string; text: string; fullName
describe('Agent Webhook - inbound flow #novu-v2', () => {
let ctx: AgentTestContext;
let inboundHandler: AgentInboundHandler;
let credentialService: AgentCredentialService;
let configResolver: AgentConfigResolver;
let bridgeCalls: BridgeExecutorParams[];

before(() => {
Expand All @@ -58,7 +68,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
beforeEach(async () => {
ctx = await setupAgentTestContext();
inboundHandler = testServer.getService(AgentInboundHandler);
credentialService = testServer.getService(AgentCredentialService);
configResolver = testServer.getService(AgentConfigResolver);

bridgeCalls = [];
const bridgeExecutor = testServer.getService(BridgeExecutorService);
Expand All @@ -68,7 +78,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
});

async function invokeInbound(threadId: string, message: ReturnType<typeof mockMessage>, event = AgentEventEnum.ON_MESSAGE) {
const config = await credentialService.resolve(ctx.agentId, ctx.integrationIdentifier);
const config = await configResolver.resolve(ctx.agentId, ctx.integrationIdentifier);
const thread = mockThread(threadId);
await inboundHandler.handle(ctx.agentId, config, thread as any, message as any, event);
}
Expand Down
40 changes: 40 additions & 0 deletions apps/api/src/app/agents/e2e/agents.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,46 @@ describe('Agents API - /agents #novu-v2', () => {
await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});

it('should update and return agent reaction settings with defaults', async () => {
const identifier = `e2e-reactions-${Date.now()}`;

const createRes = await session.testAgent.post('/v1/agents').send({
name: 'Reaction Agent',
identifier,
});

expect(createRes.status).to.equal(201);
expect(createRes.body.data.behavior).to.equal(undefined);

const setReactionsRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: {
reactions: { onMessageReceived: 'wave', onResolved: 'thumbs_up' },
},
});

expect(setReactionsRes.status).to.equal(200);
expect(setReactionsRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
expect(setReactionsRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');

const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`);

expect(getRes.status).to.equal(200);
expect(getRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
expect(getRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');

const disableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
behavior: {
reactions: { onMessageReceived: null },
},
});

expect(disableRes.status).to.equal(200);
expect(disableRes.body.data.behavior.reactions.onMessageReceived).to.equal(null);
expect(disableRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');

await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
});

it('should return 422 when identifier is not a valid slug', async () => {
const res = await session.testAgent.post('/v1/agents').send({
name: 'Invalid Slug Agent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { FeatureFlagsKeysEnum } from '@novu/shared';
import { AgentPlatformEnum } from '../dtos/agent-platform.enum';
import { resolveAgentPlatform } from '../utils/provider-to-platform';

export interface ResolvedPlatformConfig {
export interface ResolvedAgentConfig {
platform: AgentPlatformEnum;
credentials: ICredentialsEntity;
connectionAccessToken?: string;
Expand All @@ -21,14 +21,26 @@ export interface ResolvedPlatformConfig {
integrationIdentifier: string;
integrationId: string;
thinkingIndicatorEnabled: boolean;
reactionOnMessageReceived: string | null;
reactionOnResolved: string | null;
}

const DEFAULT_REACTION_ON_MESSAGE = 'eyes';
const DEFAULT_REACTION_ON_RESOLVED = 'check';

function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean {
return agent.behavior?.thinkingIndicatorEnabled !== false;
}

function resolveReaction(value: string | null | undefined, defaultEmoji: string): string | null {
if (value === null) return null;
if (value === undefined) return defaultEmoji;

return value;
}

@Injectable()
export class AgentCredentialService {
export class AgentConfigResolver {
constructor(
private readonly featureFlagsService: FeatureFlagsService,
private readonly agentRepository: AgentRepository,
Expand All @@ -37,7 +49,7 @@ export class AgentCredentialService {
private readonly channelConnectionRepository: ChannelConnectionRepository
) {}

async resolve(agentId: string, integrationIdentifier: string): Promise<ResolvedPlatformConfig> {
async resolve(agentId: string, integrationIdentifier: string): Promise<ResolvedAgentConfig> {
const agent = await this.agentRepository.findByIdForWebhook(agentId);
if (!agent) {
throw new NotFoundException(`Agent ${agentId} not found`);
Expand Down Expand Up @@ -106,6 +118,11 @@ export class AgentCredentialService {
integrationIdentifier,
integrationId: integration._id,
thinkingIndicatorEnabled: resolveThinkingIndicator(agent),
reactionOnMessageReceived: resolveReaction(
agent.behavior?.reactions?.onMessageReceived,
DEFAULT_REACTION_ON_MESSAGE
),
reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED),
};
}
}
24 changes: 20 additions & 4 deletions apps/api/src/app/agents/services/agent-inbound-handler.service.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common';
import { PinoLogger } from '@novu/application-generic';
import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal';
import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, ConversationRepository, SubscriberRepository } from '@novu/dal';
import type { Message, Thread } from 'chat';
import { AgentEventEnum } from '../dtos/agent-event.enum';
import { ResolvedAgentConfig } from './agent-config-resolver.service';
import { AgentConversationService } from './agent-conversation.service';
import { ResolvedPlatformConfig } from './agent-credential.service';
import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service';

Expand All @@ -14,13 +14,14 @@ export class AgentInboundHandler {
private readonly logger: PinoLogger,
private readonly subscriberResolver: AgentSubscriberResolver,
private readonly conversationService: AgentConversationService,
private readonly conversationRepository: ConversationRepository,
private readonly bridgeExecutor: BridgeExecutorService,
private readonly subscriberRepository: SubscriberRepository
) {}

async handle(
agentId: string,
config: ResolvedPlatformConfig,
config: ResolvedAgentConfig,
thread: Thread,
message: Message,
event: AgentEventEnum
Expand Down Expand Up @@ -75,6 +76,21 @@ export class AgentInboundHandler {
organizationId: config.organizationId,
});

const channel = conversation.channels[0];
const isFirstMessage = !channel?.firstPlatformMessageId;

if (isFirstMessage && config.reactionOnMessageReceived && message.id) {
thread.createSentMessageFromMessage(message).addReaction(config.reactionOnMessageReceived).catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
});

this.conversationRepository
.setFirstPlatformMessageId(config.environmentId, config.organizationId, conversation._id, thread.id, message.id)
.catch((err) => {
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
});
}

if (config.thinkingIndicatorEnabled) {
await thread.startTyping('Thinking...');
}
Expand Down Expand Up @@ -112,7 +128,7 @@ export class AgentInboundHandler {

async handleAction(
agentId: string,
config: ResolvedPlatformConfig,
config: ResolvedAgentConfig,
thread: Thread,
action: BridgeAction,
userId: string
Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/app/agents/services/bridge-executor.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '@novu/dal';
import type { Message } from 'chat';
import { AgentEventEnum } from '../dtos/agent-event.enum';
import { ResolvedPlatformConfig } from './agent-credential.service';
import { ResolvedAgentConfig } from './agent-config-resolver.service';

const MAX_RETRIES = 2;
const RETRY_BASE_DELAY_MS = 500;
Expand All @@ -28,7 +28,7 @@ export interface BridgePlatformContext {

export interface BridgeExecutorParams {
event: AgentEventEnum;
config: ResolvedPlatformConfig;
config: ResolvedAgentConfig;
conversation: ConversationEntity;
subscriber: SubscriberEntity | null;
history: ConversationActivityEntity[];
Expand Down
Loading
Loading