diff --git a/apps/api/src/app/agents/agents-webhook.controller.ts b/apps/api/src/app/agents/agents-webhook.controller.ts index 7432b08af77..32abbfea6fa 100644 --- a/apps/api/src/app/agents/agents-webhook.controller.ts +++ b/apps/api/src/app/agents/agents-webhook.controller.ts @@ -54,6 +54,7 @@ export class AgentsWebhookController { edit: body.edit, resolve: body.resolve, signals: body.signals as Signal[], + addReactions: body.addReactions, }) ); } diff --git a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts index 5a56a333ad5..3a468791971 100644 --- a/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts @@ -128,6 +128,18 @@ export class ResolveDto { summary?: string; } +export class AddReactionPayloadDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + messageId: string; + + @ApiProperty() + @IsString() + @IsNotEmpty() + emojiName: string; +} + export class SignalDto { @ApiProperty({ enum: SIGNAL_TYPES }) @IsString() @@ -199,4 +211,11 @@ export class AgentReplyPayloadDto { @Validate(IsValidSignal, { each: true }) @Type(() => SignalDto) signals?: SignalDto[]; + + @ApiPropertyOptional({ type: [AddReactionPayloadDto] }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddReactionPayloadDto) + addReactions?: AddReactionPayloadDto[]; } 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 0238097e2fc..bd5409ed441 100644 --- a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts @@ -297,6 +297,46 @@ 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 res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + addReactions: [ + { messageId: 'msg-abc', emojiName: 'thumbs_up' }, + { messageId: 'msg-def', emojiName: 'check' }, + ], + }); + + expect(res.status).to.equal(200); + expect((chatSdkService.reactToMessage as sinon.SinonStub).callCount).to.equal(2); + + const firstCall = (chatSdkService.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; + expect(secondCall[4]).to.equal('msg-def'); + expect(secondCall[5]).to.equal('check'); + }); + + it('should return 400 when edit and addReactions are combined', async () => { + const conversationId = await seedConversation(ctx); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + edit: { messageId: 'msg-edit', content: { markdown: 'updated' } }, + addReactions: [{ messageId: 'msg-abc', emojiName: 'thumbs_up' }], + }); + + expect(res.status).to.equal(400); + }); + }); + describe('Inactive agent', () => { it('should return 422 when agent is inactive', async () => { const conversationId = await seedConversation(ctx); diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts index 9e03d666a3d..b6cc20db8b0 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.command.ts @@ -2,7 +2,7 @@ import type { Signal } from '@novu/framework'; import { Type } from 'class-transformer'; import { IsArray, IsNotEmpty, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator'; import { EnvironmentWithUserCommand } from '../../../shared/commands/project.command'; -import { EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; +import { AddReactionPayloadDto, EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto'; export type { Signal } from '@novu/framework'; @@ -36,4 +36,10 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand { @IsOptional() @IsArray() signals?: Signal[]; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AddReactionPayloadDto) + addReactions?: AddReactionPayloadDto[]; } diff --git a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts index e46ef8fb730..7c654c52e48 100644 --- a/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts +++ b/apps/api/src/app/agents/usecases/handle-agent-reply/handle-agent-reply.usecase.ts @@ -37,11 +37,11 @@ export class HandleAgentReply { if (command.reply && command.edit) { throw new BadRequestException('Only one of reply or edit can be provided'); } - if (command.edit && (command.resolve || command.signals?.length)) { - throw new BadRequestException('edit cannot be combined with resolve or signals'); + if (command.edit && (command.resolve || command.signals?.length || command.addReactions?.length)) { + throw new BadRequestException('edit cannot be combined with resolve, signals, or addReactions'); } - if (!command.reply && !command.edit && !command.resolve && !command.signals?.length) { - throw new BadRequestException('At least one of reply, edit, resolve, or signals must be provided'); + if (!command.reply && !command.edit && !command.resolve && !command.signals?.length && !command.addReactions?.length) { + throw new BadRequestException('At least one of reply, edit, resolve, signals, or addReactions must be provided'); } const conversation = await this.conversationService.getConversation( @@ -80,6 +80,21 @@ export class HandleAgentReply { await this.executeSignals(command, conversation, channel, command.signals); } + if (command.addReactions?.length) { + await Promise.allSettled( + command.addReactions.map((r) => + this.chatSdkService.reactToMessage( + conversation._agentId, + command.integrationIdentifier, + channel.platform, + channel.platformThreadId, + r.messageId, + r.emojiName + ) + ) + ); + } + if (command.resolve) { await this.resolveConversation(command, config!, conversation, channel, command.resolve); } diff --git a/packages/framework/src/resources/agent/agent.context.ts b/packages/framework/src/resources/agent/agent.context.ts index 62a69176aed..183d3391dba 100644 --- a/packages/framework/src/resources/agent/agent.context.ts +++ b/packages/framework/src/resources/agent/agent.context.ts @@ -1,6 +1,8 @@ import { isJSX, toCardElement } from 'chat/jsx-runtime'; import { AgentDeliveryError } from './agent.errors'; +import type { Emoji } from 'chat'; import type { + AddReactionPayload, AgentAction, AgentBridgeRequest, AgentContext, @@ -108,6 +110,7 @@ export class AgentContextImpl implements AgentContext { readonly metadata: { set: (key: string, value: unknown) => void }; private _signals: Signal[] = []; + private _pendingReactions: AddReactionPayload[] = []; private _resolveSignal: { summary?: string } | null = null; private readonly _replyUrl: string; private readonly _conversationId: string; @@ -151,6 +154,11 @@ export class AgentContextImpl implements AgentContext { this._signals = []; } + if (this._pendingReactions.length) { + body.addReactions = this._pendingReactions; + this._pendingReactions = []; + } + if (this._resolveSignal) { body.resolve = this._resolveSignal; this._resolveSignal = null; @@ -178,12 +186,16 @@ export class AgentContextImpl implements AgentContext { this._signals.push({ ...opts, type: 'trigger', workflowId }); } + addReaction(messageId: string, emojiName: Emoji): void { + this._pendingReactions.push({ messageId, emojiName }); + } + /** * Flush any remaining signals that weren't sent with reply(). * Called internally after onResolve returns. */ async flush(): Promise { - if (!this._signals.length && !this._resolveSignal) { + if (!this._signals.length && !this._resolveSignal && !this._pendingReactions.length) { return; } @@ -197,6 +209,11 @@ export class AgentContextImpl implements AgentContext { this._signals = []; } + if (this._pendingReactions.length) { + body.addReactions = this._pendingReactions; + this._pendingReactions = []; + } + if (this._resolveSignal) { body.resolve = this._resolveSignal; this._resolveSignal = null; diff --git a/packages/framework/src/resources/agent/agent.test.ts b/packages/framework/src/resources/agent/agent.test.ts index edac5288fcb..521be437a46 100644 --- a/packages/framework/src/resources/agent/agent.test.ts +++ b/packages/framework/src/resources/agent/agent.test.ts @@ -921,6 +921,83 @@ describe('agent dispatch via NovuRequestHandler', () => { expect(capturedCtx.reaction.message).toBeNull(); }); + it('should flush addReaction without a reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + ctx.addReaction('msg-123', 'eyes'); + }, + }); + + 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(fetchMock).toHaveBeenCalled()); + + const replyCall = fetchMock.mock.calls.find( + (call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply' + ); + const flushBody = JSON.parse(replyCall![1].body); + + expect(flushBody.reply).toBeUndefined(); + expect(flushBody.addReactions).toHaveLength(1); + expect(flushBody.addReactions[0]).toEqual({ messageId: 'msg-123', emojiName: 'eyes' }); + }); + + it('should batch addReaction with reply', async () => { + const testBot = agent('test-bot', { + onMessage: async (ctx) => { + ctx.addReaction('msg-reacted', 'thumbs_up'); + await ctx.reply('Got it'); + }, + }); + + 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(fetchMock).toHaveBeenCalled()); + + 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.markdown).toBe('Got it'); + expect(replyBody.addReactions).toHaveLength(1); + expect(replyBody.addReactions[0]).toEqual({ messageId: 'msg-reacted', emojiName: 'thumbs_up' }); + }); + it('should have null reaction on non-reaction events', async () => { let capturedCtx: any; diff --git a/packages/framework/src/resources/agent/agent.types.ts b/packages/framework/src/resources/agent/agent.types.ts index 5cd8c9bb413..a4e5f94f315 100644 --- a/packages/framework/src/resources/agent/agent.types.ts +++ b/packages/framework/src/resources/agent/agent.types.ts @@ -1,4 +1,4 @@ -import type { CardElement, ChatElement } from 'chat'; +import type { CardElement, ChatElement, Emoji } from 'chat'; import type { TriggerRecipientsPayload } from '../../shared'; export type { TriggerRecipientsPayload }; @@ -178,6 +178,19 @@ export interface AgentContext { * ctx.trigger('team-alert', { to: { type: 'Topic', topicKey: 'support-team' } }); */ trigger(workflowId: string, opts?: { to?: TriggerRecipientsPayload; payload?: Record }): void; + /** + * Add an emoji reaction to any platform message. + * Reactions are queued and sent with the next `ctx.reply()`, or flushed automatically + * when the handler completes (same batching contract as `ctx.trigger()`). + * + * @param messageId - Platform-native message ID to react to (e.g. Slack `ts`). + * @param emojiName - Emoji short-name (e.g. `'thumbs_up'`, `'check_mark'`). + * + * @example + * ctx.addReaction(ctx.reaction!.messageId, 'check_mark'); + * await ctx.reply('Done!'); + */ + addReaction(messageId: string, emojiName: Emoji): void; } export interface AgentHandlers { @@ -241,6 +254,12 @@ export interface EditPayload { content: ReplyContent; } +/** An emoji reaction to be added to a platform message. */ +export interface AddReactionPayload { + messageId: string; + emojiName: Emoji; +} + export interface AgentReplyPayload { conversationId: string; integrationIdentifier: string; @@ -248,6 +267,7 @@ export interface AgentReplyPayload { edit?: EditPayload; resolve?: { summary?: string }; signals?: Signal[]; + addReactions?: AddReactionPayload[]; } /** Shape returned by /agents/:id/reply when a reply or edit was delivered. */