Skip to content

Commit 319360f

Browse files
authored
feat(api-service,framework): agent onReaction event fixes NV-7370 (#10733)
1 parent 4cba3eb commit 319360f

File tree

12 files changed

+471
-15
lines changed

12 files changed

+471
-15
lines changed

apps/api/src/app/agents/dtos/agent-event.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ export enum AgentEventEnum {
22
ON_MESSAGE = 'onMessage',
33
ON_ACTION = 'onAction',
44
ON_RESOLVE = 'onResolve',
5+
ON_REACTION = 'onReaction',
56
}

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

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
import { testServer } from '@novu/testing';
88
import { expect } from 'chai';
99
import sinon from 'sinon';
10-
import { AgentInboundHandler } from '../services/agent-inbound-handler.service';
10+
import { AgentInboundHandler, InboundReactionEvent } from '../services/agent-inbound-handler.service';
1111
import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service';
1212
import { AgentConfigResolver } from '../services/agent-config-resolver.service';
1313
import { AgentEventEnum } from '../dtos/agent-event.enum';
@@ -359,4 +359,119 @@ describe('Agent Webhook - inbound flow #novu-v2', () => {
359359
expect(remainingPlatformUsers.length).to.equal(0);
360360
});
361361
});
362+
363+
describe('Reaction handling', () => {
364+
async function invokeReaction(threadId: string, reaction: InboundReactionEvent) {
365+
const config = await configResolver.resolve(ctx.agentId, ctx.integrationIdentifier);
366+
await inboundHandler.handleReaction(ctx.agentId, config, reaction);
367+
}
368+
369+
it('should fire ON_REACTION bridge call for an existing conversation', async () => {
370+
const threadId = `T_REACT_${Date.now()}`;
371+
const msg = mockMessage({ userId: 'U_REACT', text: 'React to this' });
372+
373+
await invokeInbound(threadId, msg);
374+
bridgeCalls = [];
375+
376+
const reactionEvent: InboundReactionEvent = {
377+
emoji: { name: 'thumbs_up' },
378+
added: true,
379+
messageId: msg.id,
380+
message: msg as any,
381+
thread: mockThread(threadId) as any,
382+
};
383+
384+
await invokeReaction(threadId, reactionEvent);
385+
386+
expect(bridgeCalls.length).to.equal(1);
387+
const call = bridgeCalls[0];
388+
expect(call.event).to.equal(AgentEventEnum.ON_REACTION);
389+
expect(call.reaction).to.exist;
390+
expect(call.reaction!.emoji).to.equal('thumbs_up');
391+
expect(call.reaction!.added).to.equal(true);
392+
expect(call.reaction!.messageId).to.equal(msg.id);
393+
});
394+
395+
it('should skip reaction when no conversation exists for the thread', async () => {
396+
const reactionEvent: InboundReactionEvent = {
397+
emoji: { name: 'wave' },
398+
added: true,
399+
messageId: 'msg-orphan',
400+
thread: mockThread(`T_NOCONV_${Date.now()}`) as any,
401+
};
402+
403+
await invokeReaction('ignored', reactionEvent);
404+
405+
expect(bridgeCalls.length).to.equal(0);
406+
});
407+
408+
it('should skip reaction when thread context is missing', async () => {
409+
const reactionEvent: InboundReactionEvent = {
410+
emoji: { name: 'fire' },
411+
added: false,
412+
messageId: 'msg-no-thread',
413+
};
414+
415+
await invokeReaction('ignored', reactionEvent);
416+
417+
expect(bridgeCalls.length).to.equal(0);
418+
});
419+
420+
it('should include sourceMessage in reaction bridge call', async () => {
421+
const threadId = `T_REACT_MSG_${Date.now()}`;
422+
const msg = mockMessage({ userId: 'U_REACT_MSG', text: 'Source message test', fullName: 'Jane Doe' });
423+
424+
await invokeInbound(threadId, msg);
425+
bridgeCalls = [];
426+
427+
const reactionEvent: InboundReactionEvent = {
428+
emoji: { name: 'tada' },
429+
added: true,
430+
messageId: msg.id,
431+
message: msg as any,
432+
thread: mockThread(threadId) as any,
433+
};
434+
435+
await invokeReaction(threadId, reactionEvent);
436+
437+
expect(bridgeCalls.length).to.equal(1);
438+
const call = bridgeCalls[0];
439+
expect(call.reaction!.sourceMessage).to.exist;
440+
expect(call.reaction!.sourceMessage!.text).to.equal('Source message test');
441+
expect(call.reaction!.sourceMessage!.author.fullName).to.equal('Jane Doe');
442+
});
443+
444+
it('should not persist conversation activity for reactions', async () => {
445+
const threadId = `T_REACT_NOACT_${Date.now()}`;
446+
const msg = mockMessage({ userId: 'U_REACT2', text: 'Activity test' });
447+
448+
await invokeInbound(threadId, msg);
449+
450+
const conversation = await conversationRepository.findByPlatformThread(
451+
ctx.session.environment._id,
452+
ctx.session.organization._id,
453+
threadId
454+
);
455+
const activitiesBefore = await activityRepository.findByConversation(
456+
ctx.session.environment._id,
457+
conversation!._id
458+
);
459+
460+
const reactionEvent: InboundReactionEvent = {
461+
emoji: { name: 'heart' },
462+
added: true,
463+
messageId: msg.id,
464+
message: msg as any,
465+
thread: mockThread(threadId) as any,
466+
};
467+
468+
await invokeReaction(threadId, reactionEvent);
469+
470+
const activitiesAfter = await activityRepository.findByConversation(
471+
ctx.session.environment._id,
472+
conversation!._id
473+
);
474+
expect(activitiesAfter.length).to.equal(activitiesBefore.length);
475+
});
476+
});
362477
});

apps/api/src/app/agents/e2e/mock-agent-handler.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,18 @@ const echoBot = agent('novu-agent', {
128128
await ctx.reply(`Echo: ${userText}`);
129129
},
130130

131+
onReaction: async (ctx) => {
132+
console.log('\n─────────────────────────────────────────');
133+
console.log(`[${ctx.event}] reaction: ${ctx.reaction?.emoji.name} (${ctx.reaction?.added ? 'added' : 'removed'})`);
134+
console.log(`Reacted message: ${ctx.reaction?.message?.text ?? '(unavailable)'}`);
135+
console.log('─────────────────────────────────────────');
136+
137+
const emoji = ctx.reaction?.emoji.name ?? 'unknown';
138+
const added = ctx.reaction?.added ?? false;
139+
140+
await ctx.reply(`Got ${added ? '' : 'un'}reaction: :${emoji}:`);
141+
},
142+
131143
onAction: async (ctx) => {
132144
console.log('\n─────────────────────────────────────────');
133145
console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`);

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

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,21 @@ import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-re
88
import { ResolvedAgentConfig } from './agent-config-resolver.service';
99
import { AgentConversationService } from './agent-conversation.service';
1010
import { AgentSubscriberResolver } from './agent-subscriber-resolver.service';
11-
import { type BridgeAction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service';
11+
import { type BridgeAction, type BridgeReaction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service';
1212

1313
const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu*
1414
1515
Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`;
1616

17+
export interface InboundReactionEvent {
18+
emoji: { name: string };
19+
added: boolean;
20+
messageId: string;
21+
message?: Message;
22+
thread?: Thread;
23+
user?: { userId: string; fullName?: string; userName?: string };
24+
}
25+
1726
@Injectable()
1827
export class AgentInboundHandler {
1928
constructor(
@@ -154,6 +163,76 @@ export class AgentInboundHandler {
154163
}
155164
}
156165

166+
async handleReaction(
167+
agentId: string,
168+
config: ResolvedAgentConfig,
169+
event: InboundReactionEvent
170+
): Promise<void> {
171+
const threadId = event.thread?.id;
172+
if (!threadId) {
173+
this.logger.warn(`[agent:${agentId}] Reaction received without thread context, skipping`);
174+
175+
return;
176+
}
177+
178+
const conversation = await this.conversationRepository.findByPlatformThread(
179+
config.environmentId,
180+
config.organizationId,
181+
threadId
182+
);
183+
184+
if (!conversation) {
185+
return;
186+
}
187+
188+
const platformUserId = event.user?.userId;
189+
190+
const subscriberId = platformUserId
191+
? await this.subscriberResolver
192+
.resolve({
193+
environmentId: config.environmentId,
194+
organizationId: config.organizationId,
195+
platform: config.platform,
196+
platformUserId,
197+
integrationIdentifier: config.integrationIdentifier,
198+
})
199+
.catch((err) => {
200+
this.logger.warn(err, `[agent:${agentId}] Subscriber resolution failed for reaction, continuing without subscriber`);
201+
202+
return null;
203+
})
204+
: null;
205+
206+
const [subscriber, history] = await Promise.all([
207+
subscriberId
208+
? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId)
209+
: Promise.resolve(null),
210+
this.conversationService.getHistory(config.environmentId, conversation._id),
211+
]);
212+
213+
const reaction: BridgeReaction = {
214+
emoji: event.emoji.name,
215+
added: event.added,
216+
messageId: event.messageId,
217+
sourceMessage: event.message,
218+
};
219+
220+
await this.bridgeExecutor.execute({
221+
event: AgentEventEnum.ON_REACTION,
222+
config,
223+
conversation,
224+
subscriber,
225+
history,
226+
message: null,
227+
platformContext: {
228+
threadId,
229+
channelId: event.thread?.channelId ?? '',
230+
isDM: event.thread?.isDM ?? false,
231+
},
232+
reaction,
233+
});
234+
}
235+
157236
async handleAction(
158237
agentId: string,
159238
config: ResolvedAgentConfig,

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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export interface BridgePlatformContext {
2626
isDM: boolean;
2727
}
2828

29+
export interface BridgeReaction {
30+
emoji: string;
31+
added: boolean;
32+
messageId: string;
33+
sourceMessage?: Message;
34+
}
35+
2936
export interface BridgeExecutorParams {
3037
event: AgentEventEnum;
3138
config: ResolvedAgentConfig;
@@ -35,6 +42,7 @@ export interface BridgeExecutorParams {
3542
message: Message | null;
3643
platformContext: BridgePlatformContext;
3744
action?: BridgeAction;
45+
reaction?: BridgeReaction;
3846
}
3947

4048
interface BridgeMessageAuthor {
@@ -85,6 +93,13 @@ interface BridgeHistoryEntry {
8593
createdAt: string;
8694
}
8795

96+
interface BridgeReactionPayload {
97+
messageId: string;
98+
emoji: { name: string };
99+
added: boolean;
100+
message: BridgeMessage | null;
101+
}
102+
88103
export interface AgentBridgeRequest {
89104
version: 1;
90105
timestamp: string;
@@ -101,6 +116,7 @@ export interface AgentBridgeRequest {
101116
platform: string;
102117
platformContext: BridgePlatformContext;
103118
action: BridgeAction | null;
119+
reaction: BridgeReactionPayload | null;
104120
}
105121

106122
export class NoBridgeUrlError extends Error {
@@ -220,7 +236,7 @@ export class BridgeExecutorService {
220236
}
221237

222238
private buildPayload(params: BridgeExecutorParams): AgentBridgeRequest {
223-
const { event, config, conversation, subscriber, history, message, platformContext, action } = params;
239+
const { event, config, conversation, subscriber, history, message, platformContext, action, reaction } = params;
224240
const agentIdentifier = config.agentIdentifier;
225241

226242
const apiRootUrl = process.env.API_ROOT_URL || 'http://localhost:3000';
@@ -233,11 +249,13 @@ export class BridgeExecutorService {
233249
deliveryId = `${conversation._id}:${message.id}`;
234250
} else if (action) {
235251
deliveryId = `${conversation._id}:${event}:${action.actionId}:${timestamp}`;
252+
} else if (reaction) {
253+
deliveryId = `${conversation._id}:${event}:${reaction.messageId}:${timestamp}`;
236254
} else {
237255
deliveryId = `${conversation._id}:${event}`;
238256
}
239257

240-
return {
258+
const payload: AgentBridgeRequest = {
241259
version: 1,
242260
timestamp,
243261
deliveryId,
@@ -253,7 +271,10 @@ export class BridgeExecutorService {
253271
platform: config.platform,
254272
platformContext,
255273
action: action ?? null,
274+
reaction: reaction ? this.mapReaction(reaction) : null,
256275
};
276+
277+
return payload;
257278
}
258279

259280
private mapMessage(message: Message): BridgeMessage {
@@ -298,6 +319,15 @@ export class BridgeExecutorService {
298319
};
299320
}
300321

322+
private mapReaction(reaction: BridgeReaction): BridgeReactionPayload {
323+
return {
324+
messageId: reaction.messageId,
325+
emoji: { name: reaction.emoji },
326+
added: reaction.added,
327+
message: reaction.sourceMessage ? this.mapMessage(reaction.sourceMessage) : null,
328+
};
329+
}
330+
301331
private mapHistory(activities: ConversationActivityEntity[]): BridgeHistoryEntry[] {
302332
return [...activities].reverse().map((activity) => ({
303333
role: activity.senderType,

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,5 +295,20 @@ export class ChatSdkService implements OnModuleDestroy {
295295
this.logger.error(err, `[agent:${agentId}] Error handling action ${event.actionId}`);
296296
}
297297
});
298+
299+
chat.onReaction(async (event: any) => {
300+
try {
301+
await this.inboundHandler.handleReaction(agentId, config, {
302+
emoji: event.emoji,
303+
added: event.added,
304+
messageId: event.messageId,
305+
message: event.message,
306+
thread: event.thread,
307+
user: event.user,
308+
});
309+
} catch (err) {
310+
this.logger.error(err, `[agent:${agentId}] Error handling reaction`);
311+
}
312+
});
298313
}
299314
}

0 commit comments

Comments
 (0)