Skip to content

Commit d47278d

Browse files
authored
feat(api-service): add emoji reaction support to agent conversations fixes NV-7369 (#10726)
1 parent 2bb0e44 commit d47278d

File tree

16 files changed

+266
-28
lines changed

16 files changed

+266
-28
lines changed

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { SharedModule } from '../shared/shared.module';
1111
import { AgentsController } from './agents.controller';
1212
import { AgentsWebhookController } from './agents-webhook.controller';
1313
import { AgentConversationService } from './services/agent-conversation.service';
14-
import { AgentCredentialService } from './services/agent-credential.service';
14+
import { AgentConfigResolver } from './services/agent-config-resolver.service';
1515
import { AgentInboundHandler } from './services/agent-inbound-handler.service';
1616
import { AgentSubscriberResolver } from './services/agent-subscriber-resolver.service';
1717
import { BridgeExecutorService } from './services/bridge-executor.service';
@@ -27,7 +27,7 @@ import { USE_CASES } from './usecases';
2727
ChannelEndpointRepository,
2828
ConversationRepository,
2929
ConversationActivityRepository,
30-
AgentCredentialService,
30+
AgentConfigResolver,
3131
AgentSubscriberResolver,
3232
AgentConversationService,
3333
AgentInboundHandler,
Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,36 @@
11
import { ApiPropertyOptional } from '@nestjs/swagger';
2-
import { IsBoolean, IsOptional } from 'class-validator';
2+
import { IsBoolean, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator';
3+
import { Type } from 'class-transformer';
4+
5+
export class AgentReactionSettingsDto {
6+
@ApiPropertyOptional({
7+
description: 'Emoji reaction for incoming messages. Emoji name string to customize, null to disable. Default: "eyes" (👀)',
8+
default: 'eyes',
9+
})
10+
@IsOptional()
11+
@ValidateIf((_, value) => value !== null)
12+
@IsString()
13+
onMessageReceived?: string | null;
14+
15+
@ApiPropertyOptional({
16+
description: 'Emoji reaction when a conversation is resolved. Emoji name string to customize, null to disable. Default: "check" (✅)',
17+
default: 'check',
18+
})
19+
@IsOptional()
20+
@ValidateIf((_, value) => value !== null)
21+
@IsString()
22+
onResolved?: string | null;
23+
}
324

425
export class AgentBehaviorDto {
526
@ApiPropertyOptional({ description: 'Show a "Thinking..." indicator while the agent is processing a message' })
627
@IsBoolean()
728
@IsOptional()
829
thinkingIndicatorEnabled?: boolean;
30+
31+
@ApiPropertyOptional({ type: AgentReactionSettingsDto, description: 'Automatic emoji reactions on messages' })
32+
@ValidateNested()
33+
@Type(() => AgentReactionSettingsDto)
34+
@IsOptional()
35+
reactions?: AgentReactionSettingsDto;
936
}

apps/api/src/app/agents/e2e/agent-reply.e2e.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
3535

3636
const chatSdkService = testServer.getService(ChatSdkService);
3737
sinon.stub(chatSdkService, 'postToConversation').resolves();
38+
sinon.stub(chatSdkService, 'reactToMessage').resolves();
39+
sinon.stub(chatSdkService, 'removeReaction').resolves();
3840
});
3941

4042
function postReply(body: Record<string, unknown>) {

apps/api/src/app/agents/e2e/agent-webhook.e2e.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { expect } from 'chai';
99
import sinon from 'sinon';
1010
import { AgentInboundHandler } from '../services/agent-inbound-handler.service';
1111
import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service';
12-
import { AgentCredentialService } from '../services/agent-credential.service';
12+
import { AgentConfigResolver } from '../services/agent-config-resolver.service';
1313
import { AgentEventEnum } from '../dtos/agent-event.enum';
1414
import {
1515
setupAgentTestContext,
@@ -20,6 +20,15 @@ import {
2020
} from './helpers/agent-test-setup';
2121
import { signSlackRequest, buildSlackChallenge } from './helpers/providers/slack';
2222

23+
function mockSentMessage() {
24+
return {
25+
addReaction: async () => {},
26+
removeReaction: async () => {},
27+
edit: async () => mockSentMessage(),
28+
delete: async () => {},
29+
};
30+
}
31+
2332
function mockThread(id: string, channelId = 'C_TEST') {
2433
return {
2534
id,
@@ -28,6 +37,7 @@ function mockThread(id: string, channelId = 'C_TEST') {
2837
startTyping: async () => {},
2938
subscribe: async () => {},
3039
toJSON: () => ({ id, platform: 'slack', channelId, serialized: true }),
40+
createSentMessageFromMessage: () => mockSentMessage(),
3141
};
3242
}
3343

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

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

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

7080
async function invokeInbound(threadId: string, message: ReturnType<typeof mockMessage>, event = AgentEventEnum.ON_MESSAGE) {
71-
const config = await credentialService.resolve(ctx.agentId, ctx.integrationIdentifier);
81+
const config = await configResolver.resolve(ctx.agentId, ctx.integrationIdentifier);
7282
const thread = mockThread(threadId);
7383
await inboundHandler.handle(ctx.agentId, config, thread as any, message as any, event);
7484
}

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,46 @@ describe('Agents API - /agents #novu-v2', () => {
9898
await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
9999
});
100100

101+
it('should update and return agent reaction settings with defaults', async () => {
102+
const identifier = `e2e-reactions-${Date.now()}`;
103+
104+
const createRes = await session.testAgent.post('/v1/agents').send({
105+
name: 'Reaction Agent',
106+
identifier,
107+
});
108+
109+
expect(createRes.status).to.equal(201);
110+
expect(createRes.body.data.behavior).to.equal(undefined);
111+
112+
const setReactionsRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
113+
behavior: {
114+
reactions: { onMessageReceived: 'wave', onResolved: 'thumbs_up' },
115+
},
116+
});
117+
118+
expect(setReactionsRes.status).to.equal(200);
119+
expect(setReactionsRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
120+
expect(setReactionsRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
121+
122+
const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`);
123+
124+
expect(getRes.status).to.equal(200);
125+
expect(getRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave');
126+
expect(getRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
127+
128+
const disableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({
129+
behavior: {
130+
reactions: { onMessageReceived: null },
131+
},
132+
});
133+
134+
expect(disableRes.status).to.equal(200);
135+
expect(disableRes.body.data.behavior.reactions.onMessageReceived).to.equal(null);
136+
expect(disableRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up');
137+
138+
await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`);
139+
});
140+
101141
it('should return 422 when identifier is not a valid slug', async () => {
102142
const res = await session.testAgent.post('/v1/agents').send({
103143
name: 'Invalid Slug Agent',

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { FeatureFlagsKeysEnum } from '@novu/shared';
1111
import { AgentPlatformEnum } from '../dtos/agent-platform.enum';
1212
import { resolveAgentPlatform } from '../utils/provider-to-platform';
1313

14-
export interface ResolvedPlatformConfig {
14+
export interface ResolvedAgentConfig {
1515
platform: AgentPlatformEnum;
1616
credentials: ICredentialsEntity;
1717
connectionAccessToken?: string;
@@ -21,14 +21,26 @@ export interface ResolvedPlatformConfig {
2121
integrationIdentifier: string;
2222
integrationId: string;
2323
thinkingIndicatorEnabled: boolean;
24+
reactionOnMessageReceived: string | null;
25+
reactionOnResolved: string | null;
2426
}
2527

28+
const DEFAULT_REACTION_ON_MESSAGE = 'eyes';
29+
const DEFAULT_REACTION_ON_RESOLVED = 'check';
30+
2631
function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean {
2732
return agent.behavior?.thinkingIndicatorEnabled !== false;
2833
}
2934

35+
function resolveReaction(value: string | null | undefined, defaultEmoji: string): string | null {
36+
if (value === null) return null;
37+
if (value === undefined) return defaultEmoji;
38+
39+
return value;
40+
}
41+
3042
@Injectable()
31-
export class AgentCredentialService {
43+
export class AgentConfigResolver {
3244
constructor(
3345
private readonly featureFlagsService: FeatureFlagsService,
3446
private readonly agentRepository: AgentRepository,
@@ -37,7 +49,7 @@ export class AgentCredentialService {
3749
private readonly channelConnectionRepository: ChannelConnectionRepository
3850
) {}
3951

40-
async resolve(agentId: string, integrationIdentifier: string): Promise<ResolvedPlatformConfig> {
52+
async resolve(agentId: string, integrationIdentifier: string): Promise<ResolvedAgentConfig> {
4153
const agent = await this.agentRepository.findByIdForWebhook(agentId);
4254
if (!agent) {
4355
throw new NotFoundException(`Agent ${agentId} not found`);
@@ -106,6 +118,11 @@ export class AgentCredentialService {
106118
integrationIdentifier,
107119
integrationId: integration._id,
108120
thinkingIndicatorEnabled: resolveThinkingIndicator(agent),
121+
reactionOnMessageReceived: resolveReaction(
122+
agent.behavior?.reactions?.onMessageReceived,
123+
DEFAULT_REACTION_ON_MESSAGE
124+
),
125+
reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED),
109126
};
110127
}
111128
}

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { Injectable } from '@nestjs/common';
22
import { PinoLogger } from '@novu/application-generic';
3-
import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal';
3+
import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, ConversationRepository, SubscriberRepository } from '@novu/dal';
44
import type { Message, Thread } from 'chat';
55
import { AgentEventEnum } from '../dtos/agent-event.enum';
6+
import { ResolvedAgentConfig } from './agent-config-resolver.service';
67
import { AgentConversationService } from './agent-conversation.service';
7-
import { ResolvedPlatformConfig } from './agent-credential.service';
88
import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
99
import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service';
1010

@@ -14,13 +14,14 @@ export class AgentInboundHandler {
1414
private readonly logger: PinoLogger,
1515
private readonly subscriberResolver: AgentSubscriberResolver,
1616
private readonly conversationService: AgentConversationService,
17+
private readonly conversationRepository: ConversationRepository,
1718
private readonly bridgeExecutor: BridgeExecutorService,
1819
private readonly subscriberRepository: SubscriberRepository
1920
) {}
2021

2122
async handle(
2223
agentId: string,
23-
config: ResolvedPlatformConfig,
24+
config: ResolvedAgentConfig,
2425
thread: Thread,
2526
message: Message,
2627
event: AgentEventEnum
@@ -75,6 +76,21 @@ export class AgentInboundHandler {
7576
organizationId: config.organizationId,
7677
});
7778

79+
const channel = conversation.channels[0];
80+
const isFirstMessage = !channel?.firstPlatformMessageId;
81+
82+
if (isFirstMessage && config.reactionOnMessageReceived && message.id) {
83+
thread.createSentMessageFromMessage(message).addReaction(config.reactionOnMessageReceived).catch((err) => {
84+
this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`);
85+
});
86+
87+
this.conversationRepository
88+
.setFirstPlatformMessageId(config.environmentId, config.organizationId, conversation._id, thread.id, message.id)
89+
.catch((err) => {
90+
this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`);
91+
});
92+
}
93+
7894
if (config.thinkingIndicatorEnabled) {
7995
await thread.startTyping('Thinking...');
8096
}
@@ -112,7 +128,7 @@ export class AgentInboundHandler {
112128

113129
async handleAction(
114130
agentId: string,
115-
config: ResolvedPlatformConfig,
131+
config: ResolvedAgentConfig,
116132
thread: Thread,
117133
action: BridgeAction,
118134
userId: string

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
} from '@novu/dal';
1616
import type { Message } from 'chat';
1717
import { AgentEventEnum } from '../dtos/agent-event.enum';
18-
import { ResolvedPlatformConfig } from './agent-credential.service';
18+
import { ResolvedAgentConfig } from './agent-config-resolver.service';
1919

2020
const MAX_RETRIES = 2;
2121
const RETRY_BASE_DELAY_MS = 500;
@@ -28,7 +28,7 @@ export interface BridgePlatformContext {
2828

2929
export interface BridgeExecutorParams {
3030
event: AgentEventEnum;
31-
config: ResolvedPlatformConfig;
31+
config: ResolvedAgentConfig;
3232
conversation: ConversationEntity;
3333
subscriber: SubscriberEntity | null;
3434
history: ConversationActivityEntity[];

0 commit comments

Comments
 (0)