diff --git a/apps/api/src/app/agents/dtos/agent-event.enum.ts b/apps/api/src/app/agents/dtos/agent-event.enum.ts index 12280082d34..f00cc60e737 100644 --- a/apps/api/src/app/agents/dtos/agent-event.enum.ts +++ b/apps/api/src/app/agents/dtos/agent-event.enum.ts @@ -2,4 +2,5 @@ export enum AgentEventEnum { ON_MESSAGE = 'onMessage', ON_ACTION = 'onAction', ON_RESOLVE = 'onResolve', + ON_REACTION = 'onReaction', } 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 a236113d679..2a21957fbee 100644 --- a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts @@ -7,7 +7,7 @@ import { import { testServer } from '@novu/testing'; import { expect } from 'chai'; import sinon from 'sinon'; -import { AgentInboundHandler } from '../services/agent-inbound-handler.service'; +import { AgentInboundHandler, InboundReactionEvent } from '../services/agent-inbound-handler.service'; import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service'; import { AgentConfigResolver } from '../services/agent-config-resolver.service'; import { AgentEventEnum } from '../dtos/agent-event.enum'; @@ -359,4 +359,119 @@ describe('Agent Webhook - inbound flow #novu-v2', () => { expect(remainingPlatformUsers.length).to.equal(0); }); }); + + describe('Reaction handling', () => { + async function invokeReaction(threadId: string, reaction: InboundReactionEvent) { + const config = await configResolver.resolve(ctx.agentId, ctx.integrationIdentifier); + await inboundHandler.handleReaction(ctx.agentId, config, reaction); + } + + it('should fire ON_REACTION bridge call for an existing conversation', async () => { + const threadId = `T_REACT_${Date.now()}`; + const msg = mockMessage({ userId: 'U_REACT', text: 'React to this' }); + + await invokeInbound(threadId, msg); + bridgeCalls = []; + + const reactionEvent: InboundReactionEvent = { + emoji: { name: 'thumbs_up' }, + added: true, + messageId: msg.id, + message: msg as any, + thread: mockThread(threadId) as any, + }; + + await invokeReaction(threadId, reactionEvent); + + expect(bridgeCalls.length).to.equal(1); + const call = bridgeCalls[0]; + expect(call.event).to.equal(AgentEventEnum.ON_REACTION); + expect(call.reaction).to.exist; + expect(call.reaction!.emoji).to.equal('thumbs_up'); + expect(call.reaction!.added).to.equal(true); + expect(call.reaction!.messageId).to.equal(msg.id); + }); + + it('should skip reaction when no conversation exists for the thread', async () => { + const reactionEvent: InboundReactionEvent = { + emoji: { name: 'wave' }, + added: true, + messageId: 'msg-orphan', + thread: mockThread(`T_NOCONV_${Date.now()}`) as any, + }; + + await invokeReaction('ignored', reactionEvent); + + expect(bridgeCalls.length).to.equal(0); + }); + + it('should skip reaction when thread context is missing', async () => { + const reactionEvent: InboundReactionEvent = { + emoji: { name: 'fire' }, + added: false, + messageId: 'msg-no-thread', + }; + + await invokeReaction('ignored', reactionEvent); + + expect(bridgeCalls.length).to.equal(0); + }); + + it('should include sourceMessage in reaction bridge call', async () => { + const threadId = `T_REACT_MSG_${Date.now()}`; + const msg = mockMessage({ userId: 'U_REACT_MSG', text: 'Source message test', fullName: 'Jane Doe' }); + + await invokeInbound(threadId, msg); + bridgeCalls = []; + + const reactionEvent: InboundReactionEvent = { + emoji: { name: 'tada' }, + added: true, + messageId: msg.id, + message: msg as any, + thread: mockThread(threadId) as any, + }; + + await invokeReaction(threadId, reactionEvent); + + expect(bridgeCalls.length).to.equal(1); + const call = bridgeCalls[0]; + expect(call.reaction!.sourceMessage).to.exist; + expect(call.reaction!.sourceMessage!.text).to.equal('Source message test'); + expect(call.reaction!.sourceMessage!.author.fullName).to.equal('Jane Doe'); + }); + + it('should not persist conversation activity for reactions', async () => { + const threadId = `T_REACT_NOACT_${Date.now()}`; + const msg = mockMessage({ userId: 'U_REACT2', text: 'Activity test' }); + + await invokeInbound(threadId, msg); + + const conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + const activitiesBefore = await activityRepository.findByConversation( + ctx.session.environment._id, + conversation!._id + ); + + const reactionEvent: InboundReactionEvent = { + emoji: { name: 'heart' }, + added: true, + messageId: msg.id, + message: msg as any, + thread: mockThread(threadId) as any, + }; + + await invokeReaction(threadId, reactionEvent); + + const activitiesAfter = await activityRepository.findByConversation( + ctx.session.environment._id, + conversation!._id + ); + expect(activitiesAfter.length).to.equal(activitiesBefore.length); + }); + }); }); diff --git a/apps/api/src/app/agents/e2e/mock-agent-handler.ts b/apps/api/src/app/agents/e2e/mock-agent-handler.ts index ece600d3601..33787df2d25 100644 --- a/apps/api/src/app/agents/e2e/mock-agent-handler.ts +++ b/apps/api/src/app/agents/e2e/mock-agent-handler.ts @@ -128,6 +128,18 @@ const echoBot = agent('novu-agent', { await ctx.reply(`Echo: ${userText}`); }, + onReaction: async (ctx) => { + console.log('\n─────────────────────────────────────────'); + console.log(`[${ctx.event}] reaction: ${ctx.reaction?.emoji.name} (${ctx.reaction?.added ? 'added' : 'removed'})`); + console.log(`Reacted message: ${ctx.reaction?.message?.text ?? '(unavailable)'}`); + console.log('─────────────────────────────────────────'); + + const emoji = ctx.reaction?.emoji.name ?? 'unknown'; + const added = ctx.reaction?.added ?? false; + + await ctx.reply(`Got ${added ? '' : 'un'}reaction: :${emoji}:`); + }, + onAction: async (ctx) => { console.log('\n─────────────────────────────────────────'); console.log(`[${ctx.event}] action: ${ctx.action?.actionId} = ${ctx.action?.value ?? '(no value)'}`); diff --git a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts index f23b72a21d2..a81d01ef0d2 100644 --- a/apps/api/src/app/agents/services/agent-inbound-handler.service.ts +++ b/apps/api/src/app/agents/services/agent-inbound-handler.service.ts @@ -8,12 +8,21 @@ import { HandleAgentReply } from '../usecases/handle-agent-reply/handle-agent-re import { ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentConversationService } from './agent-conversation.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; -import { type BridgeAction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service'; +import { type BridgeAction, type BridgeReaction, BridgeExecutorService, NoBridgeUrlError } from './bridge-executor.service'; const ONBOARDING_NO_BRIDGE_REPLY_MARKDOWN = `*You're connected to Novu* Your bot is linked successfully. Go back to the *Novu dashboard* to complete onboarding.`; +export interface InboundReactionEvent { + emoji: { name: string }; + added: boolean; + messageId: string; + message?: Message; + thread?: Thread; + user?: { userId: string; fullName?: string; userName?: string }; +} + @Injectable() export class AgentInboundHandler { constructor( @@ -154,6 +163,76 @@ export class AgentInboundHandler { } } + async handleReaction( + agentId: string, + config: ResolvedAgentConfig, + event: InboundReactionEvent + ): Promise { + const threadId = event.thread?.id; + if (!threadId) { + this.logger.warn(`[agent:${agentId}] Reaction received without thread context, skipping`); + + return; + } + + const conversation = await this.conversationRepository.findByPlatformThread( + config.environmentId, + config.organizationId, + threadId + ); + + if (!conversation) { + return; + } + + 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`); + + return null; + }) + : null; + + const [subscriber, history] = await Promise.all([ + subscriberId + ? this.subscriberRepository.findBySubscriberId(config.environmentId, subscriberId) + : Promise.resolve(null), + this.conversationService.getHistory(config.environmentId, conversation._id), + ]); + + const reaction: BridgeReaction = { + emoji: event.emoji.name, + added: event.added, + messageId: event.messageId, + sourceMessage: event.message, + }; + + await this.bridgeExecutor.execute({ + event: AgentEventEnum.ON_REACTION, + config, + conversation, + subscriber, + history, + message: null, + platformContext: { + threadId, + channelId: event.thread?.channelId ?? '', + isDM: event.thread?.isDM ?? false, + }, + reaction, + }); + } + async handleAction( agentId: string, config: ResolvedAgentConfig, diff --git a/apps/api/src/app/agents/services/bridge-executor.service.ts b/apps/api/src/app/agents/services/bridge-executor.service.ts index f6adbd0545b..49458b9e07c 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -26,6 +26,13 @@ export interface BridgePlatformContext { isDM: boolean; } +export interface BridgeReaction { + emoji: string; + added: boolean; + messageId: string; + sourceMessage?: Message; +} + export interface BridgeExecutorParams { event: AgentEventEnum; config: ResolvedAgentConfig; @@ -35,6 +42,7 @@ export interface BridgeExecutorParams { message: Message | null; platformContext: BridgePlatformContext; action?: BridgeAction; + reaction?: BridgeReaction; } interface BridgeMessageAuthor { @@ -85,6 +93,13 @@ interface BridgeHistoryEntry { createdAt: string; } +interface BridgeReactionPayload { + messageId: string; + emoji: { name: string }; + added: boolean; + message: BridgeMessage | null; +} + export interface AgentBridgeRequest { version: 1; timestamp: string; @@ -101,6 +116,7 @@ export interface AgentBridgeRequest { platform: string; platformContext: BridgePlatformContext; action: BridgeAction | null; + reaction: BridgeReactionPayload | null; } export class NoBridgeUrlError extends Error { @@ -220,7 +236,7 @@ export class BridgeExecutorService { } private buildPayload(params: BridgeExecutorParams): AgentBridgeRequest { - const { event, config, conversation, subscriber, history, message, platformContext, action } = params; + const { event, config, conversation, subscriber, history, message, platformContext, action, reaction } = params; const agentIdentifier = config.agentIdentifier; const apiRootUrl = process.env.API_ROOT_URL || 'http://localhost:3000'; @@ -233,11 +249,13 @@ export class BridgeExecutorService { deliveryId = `${conversation._id}:${message.id}`; } else if (action) { deliveryId = `${conversation._id}:${event}:${action.actionId}:${timestamp}`; + } else if (reaction) { + deliveryId = `${conversation._id}:${event}:${reaction.messageId}:${timestamp}`; } else { deliveryId = `${conversation._id}:${event}`; } - return { + const payload: AgentBridgeRequest = { version: 1, timestamp, deliveryId, @@ -253,7 +271,10 @@ export class BridgeExecutorService { platform: config.platform, platformContext, action: action ?? null, + reaction: reaction ? this.mapReaction(reaction) : null, }; + + return payload; } private mapMessage(message: Message): BridgeMessage { @@ -298,6 +319,15 @@ export class BridgeExecutorService { }; } + private mapReaction(reaction: BridgeReaction): BridgeReactionPayload { + return { + messageId: reaction.messageId, + emoji: { name: reaction.emoji }, + added: reaction.added, + message: reaction.sourceMessage ? this.mapMessage(reaction.sourceMessage) : null, + }; + } + private mapHistory(activities: ConversationActivityEntity[]): BridgeHistoryEntry[] { return [...activities].reverse().map((activity) => ({ role: activity.senderType, diff --git a/apps/api/src/app/agents/services/chat-sdk.service.ts b/apps/api/src/app/agents/services/chat-sdk.service.ts index 96b1247dbeb..6bb24076f6b 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -295,5 +295,20 @@ export class ChatSdkService implements OnModuleDestroy { this.logger.error(err, `[agent:${agentId}] Error handling action ${event.actionId}`); } }); + + chat.onReaction(async (event: any) => { + try { + await this.inboundHandler.handleReaction(agentId, config, { + emoji: event.emoji, + added: event.added, + messageId: event.messageId, + message: event.message, + thread: event.thread, + user: event.user, + }); + } catch (err) { + this.logger.error(err, `[agent:${agentId}] Error handling reaction`); + } + }); } } diff --git a/packages/framework/src/handler.ts b/packages/framework/src/handler.ts index 500bc342ef6..c12ce38b277 100644 --- a/packages/framework/src/handler.ts +++ b/packages/framework/src/handler.ts @@ -297,20 +297,22 @@ export class NovuRequestHandler { } private async runAgentHandler(registeredAgent: Agent, event: string, ctx: AgentContextImpl): Promise { - if (event === AgentEventEnum.ON_RESOLVE) { - if (registeredAgent.handlers.onResolve) { - await registeredAgent.handlers.onResolve(ctx); - } - } else if (event === AgentEventEnum.ON_ACTION) { - if (registeredAgent.handlers.onAction) { - await registeredAgent.handlers.onAction(ctx); - } - } else if (event === AgentEventEnum.ON_MESSAGE) { - await registeredAgent.handlers.onMessage(ctx); - } else { + const handlerMap: Partial Promise>> = { + [AgentEventEnum.ON_MESSAGE]: registeredAgent.handlers.onMessage, + [AgentEventEnum.ON_REACTION]: registeredAgent.handlers.onReaction, + [AgentEventEnum.ON_ACTION]: registeredAgent.handlers.onAction, + [AgentEventEnum.ON_RESOLVE]: registeredAgent.handlers.onResolve, + }; + + if (!Object.prototype.hasOwnProperty.call(handlerMap, event)) { throw new InvalidActionError(event, AgentEventEnum); } + const handler = handlerMap[event as AgentEventEnum]; + if (handler) { + await handler(ctx); + } + await ctx.flush(); } diff --git a/packages/framework/src/index.ts b/packages/framework/src/index.ts index 69e0a18379a..36d6b8ed72c 100644 --- a/packages/framework/src/index.ts +++ b/packages/framework/src/index.ts @@ -6,6 +6,7 @@ export type { Agent, AgentContext, AgentHandlers, + AgentReaction, CardElement, CardChild, FileRef, diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index e7c4b0e65f4..47120b1e91c 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -6,6 +6,7 @@ import type { AgentHistoryEntry, AgentMessage, AgentPlatformContext, + AgentReaction, AgentReplyPayload, AgentSubscriber, MessageContent, @@ -42,6 +43,7 @@ export class AgentContextImpl implements AgentContext { readonly event: string; readonly action: AgentAction | null; readonly message: AgentMessage | null; + readonly reaction: AgentReaction | null; readonly conversation: AgentConversation; readonly subscriber: AgentSubscriber | null; readonly history: AgentHistoryEntry[]; @@ -61,6 +63,7 @@ export class AgentContextImpl implements AgentContext { this.event = request.event; this.action = request.action ?? null; this.message = request.message; + this.reaction = request.reaction; this.conversation = request.conversation; this.subscriber = request.subscriber; this.history = request.history; diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts index 4d320e7ddd0..b4588699a85 100644 --- a/packages/framework/src/resources/agent/agent.test.ts +++ b/packages/framework/src/resources/agent/agent.test.ts @@ -18,6 +18,7 @@ function createMockBridgeRequest(overrides?: Partial): Agent conversationId: 'conv-456', integrationIdentifier: 'slack-main', action: null, + reaction: null, message: { text: 'Hello bot!', platformMessageId: 'msg-789', @@ -59,6 +60,12 @@ describe('agent()', () => { it('should throw when onMessage is missing', () => { expect(() => agent('wine-bot', {} as any)).toThrow('onMessage handler'); }); + + it('should accept agent without onReaction handler', () => { + const bot = agent('wine-bot', { onMessage: async () => {} }); + + expect(bot.handlers.onReaction).toBeUndefined(); + }); }); describe('agent dispatch via NovuRequestHandler', () => { @@ -653,4 +660,183 @@ describe('agent dispatch via NovuRequestHandler', () => { expect(result.status).toBe(200); expect(JSON.parse(result.body).status).toBe('ack'); }); + + it('should silently skip onReaction when no handler registered', async () => { + const testBot = agent('test-bot', { + onMessage: async () => {}, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onReaction', + message: null, + reaction: { + messageId: 'msg-123', + emoji: { name: 'thumbs_up' }, + added: true, + message: null, + }, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onReaction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + const result = await handler.createHandler()(); + expect(result.status).toBe(200); + expect(JSON.parse(result.body).status).toBe('ack'); + }); + + it('should dispatch onReaction event with reaction data on ctx', async () => { + let capturedCtx: any; + + const testBot = agent('test-bot', { + onMessage: async () => {}, + onReaction: async (ctx) => { + capturedCtx = ctx; + await ctx.reply('Reaction received'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onReaction', + message: null, + reaction: { + messageId: 'msg-reacted', + emoji: { name: 'thumbs_up' }, + added: true, + message: { + text: 'Hello bot!', + platformMessageId: 'msg-reacted', + author: { userId: 'u1', fullName: 'Alice', userName: 'alice', isBot: false }, + timestamp: new Date().toISOString(), + }, + }, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onReaction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(capturedCtx).toBeDefined()); + + expect(capturedCtx.event).toBe('onReaction'); + expect(capturedCtx.reaction).toBeDefined(); + expect(capturedCtx.reaction.messageId).toBe('msg-reacted'); + expect(capturedCtx.reaction.emoji.name).toBe('thumbs_up'); + expect(capturedCtx.reaction.added).toBe(true); + expect(capturedCtx.reaction.message).toBeDefined(); + expect(capturedCtx.reaction.message.text).toBe('Hello bot!'); + expect(capturedCtx.reaction.message.platformMessageId).toBe('msg-reacted'); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const replyBody = JSON.parse(replyCall![1].body); + expect(replyBody.reply.text).toBe('Reaction received'); + }); + + it('should have null reaction.message when messageText is not provided', async () => { + let capturedCtx: any; + + const testBot = agent('test-bot', { + onMessage: async () => {}, + onReaction: async (ctx) => { + capturedCtx = ctx; + await ctx.reply('ok'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest({ + event: 'onReaction', + message: null, + reaction: { + messageId: 'msg-456', + emoji: { name: 'heart' }, + added: false, + message: null, + }, + }); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onReaction`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(capturedCtx).toBeDefined()); + + expect(capturedCtx.reaction.emoji.name).toBe('heart'); + expect(capturedCtx.reaction.added).toBe(false); + expect(capturedCtx.reaction.message).toBeNull(); + }); + + it('should have null reaction on non-reaction events', async () => { + let capturedCtx: any; + + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + capturedCtx = ctx; + await ctx.reply('ok'); + }, + }); + + const handler = new NovuRequestHandler({ + frameworkName: 'test', + agents: [testBot], + client, + handler: () => { + const body = createMockBridgeRequest(); + const url = new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`); + + return { + body: () => body, + headers: () => null, + method: () => 'POST', + url: () => url, + transformResponse: (res: any) => res, + }; + }, + }); + + await handler.createHandler()(); + await vi.waitFor(() => expect(capturedCtx).toBeDefined()); + + expect(capturedCtx.reaction).toBeNull(); + }); }); diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts index a4f36518bf7..3be5b869a35 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -4,6 +4,7 @@ export enum AgentEventEnum { ON_MESSAGE = 'onMessage', ON_ACTION = 'onAction', ON_RESOLVE = 'onResolve', + ON_REACTION = 'onReaction', } // --------------------------------------------------------------------------- @@ -102,10 +103,18 @@ export interface AgentAction { // Context + handlers // --------------------------------------------------------------------------- +export interface AgentReaction { + messageId: string; + emoji: { name: string }; + added: boolean; + message: AgentMessage | null; +} + export interface AgentContext { readonly event: string; readonly action: AgentAction | null; readonly message: AgentMessage | null; + readonly reaction: AgentReaction | null; readonly conversation: AgentConversation; readonly subscriber: AgentSubscriber | null; readonly history: AgentHistoryEntry[]; @@ -123,6 +132,7 @@ export interface AgentContext { export interface AgentHandlers { onMessage: (ctx: AgentContext) => Promise; + onReaction?: (ctx: AgentContext) => Promise; onAction?: (ctx: AgentContext) => Promise; onResolve?: (ctx: AgentContext) => Promise; } @@ -147,6 +157,7 @@ export interface AgentBridgeRequest { integrationIdentifier: string; action: AgentAction | null; message: AgentMessage | null; + reaction: AgentReaction | null; conversation: AgentConversation; subscriber: AgentSubscriber | null; history: AgentHistoryEntry[]; diff --git a/packages/framework/src/resources/agent/index.ts b/packages/framework/src/resources/agent/index.ts index 122676c8041..5bd353f425d 100644 --- a/packages/framework/src/resources/agent/index.ts +++ b/packages/framework/src/resources/agent/index.ts @@ -11,6 +11,7 @@ export type { AgentMessage, AgentMessageAuthor, AgentPlatformContext, + AgentReaction, AgentReplyPayload, AgentSubscriber, FileRef,