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
2 changes: 1 addition & 1 deletion apps/api/src/app/agents/agents-webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class AgentsWebhookController {
agentIdentifier: agentId,
integrationIdentifier: body.integrationIdentifier,
reply: body.reply,
update: body.update,
edit: body.edit,
resolve: body.resolve,
signals: body.signals as Signal[],
})
Expand Down
21 changes: 17 additions & 4 deletions apps/api/src/app/agents/dtos/agent-reply-payload.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ export class ReplyContentDto {
files?: FileRef[];
}

export class EditPayloadDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
messageId: string;

@ApiProperty({ type: ReplyContentDto })
@IsObject()
@ValidateNested()
@Validate(IsValidReplyContent)
@Type(() => ReplyContentDto)
content: ReplyContentDto;
}

export class ResolveDto {
@ApiPropertyOptional()
@IsOptional()
Expand Down Expand Up @@ -144,13 +158,12 @@ export class AgentReplyPayloadDto {
@Type(() => ReplyContentDto)
reply?: ReplyContentDto;

@ApiPropertyOptional({ type: ReplyContentDto })
@ApiPropertyOptional({ type: EditPayloadDto })
@IsOptional()
@IsObject()
@ValidateNested()
@Validate(IsValidReplyContent)
@Type(() => ReplyContentDto)
update?: ReplyContentDto;
@Type(() => EditPayloadDto)
edit?: EditPayloadDto;

@ApiPropertyOptional({ type: ResolveDto })
@IsOptional()
Expand Down
75 changes: 61 additions & 14 deletions apps/api/src/app/agents/e2e/agent-reply.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});

const chatSdkService = testServer.getService(ChatSdkService);
sinon.stub(chatSdkService, 'postToConversation').resolves();
sinon
.stub(chatSdkService, 'postToConversation')
.resolves({ messageId: 'platform-msg-1', platformThreadId: 'platform-thread-1' });
sinon
.stub(chatSdkService, 'editInConversation')
.resolves({ messageId: 'platform-msg-1', platformThreadId: 'platform-thread-1' });
sinon.stub(chatSdkService, 'reactToMessage').resolves();
sinon.stub(chatSdkService, 'removeReaction').resolves();
});
Expand All @@ -61,7 +66,7 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});

expect(res.status).to.equal(200);
expect(res.body.data.status).to.equal('ok');
expect(res.body.data?.messageId).to.equal('platform-msg-1');

const convAfter = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
Expand All @@ -81,42 +86,83 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
expect(agentActivity!.content).to.equal('Hello from agent');
});

it('should persist update activity and return early without executing resolve', async () => {
it('should return messageId/platformThreadId on successful reply', async () => {
const conversationId = await seedConversation(ctx);

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
update: { text: 'Processing...' },
resolve: { summary: 'Should be ignored' },
reply: { text: 'Hello' },
});

expect(res.status).to.equal(200);
expect(res.body.data.status).to.equal('update_sent');
expect(res.body.data.messageId).to.equal('platform-msg-1');
expect(res.body.data.platformThreadId).to.equal('platform-thread-1');
});

it('should edit a previously sent message and persist an edit activity', async () => {
const conversationId = await seedConversation(ctx);

const convBefore = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
'*'
);
const countBefore = convBefore!.messageCount;

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
edit: {
messageId: 'platform-msg-1',
content: { text: 'Edited content' },
},
});

expect(res.status).to.equal(200);
expect(res.body.data.messageId).to.equal('platform-msg-1');
expect(res.body.data.platformThreadId).to.equal('platform-thread-1');

const activities = await activityRepository.findByConversation(
ctx.session.environment._id,
conversationId
);
const updateActivity = activities.find((a) => a.type === ConversationActivityTypeEnum.UPDATE);
expect(updateActivity).to.exist;
expect(updateActivity!.content).to.equal('Processing...');
const editActivity = activities.find((a) => a.type === ConversationActivityTypeEnum.EDIT);
expect(editActivity).to.exist;
expect(editActivity!.content).to.equal('Edited content');
expect(editActivity!.platformMessageId).to.equal('platform-msg-1');

const conversation = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
'*'
);
expect(conversation!.status).to.equal(ConversationStatusEnum.ACTIVE);
// Edit refreshes the conversation's lastMessagePreview to the new content...
expect(conversation!.lastMessagePreview).to.equal('Edited content');
// ...without bumping messageCount (edits mutate an existing message, not add one).
expect(conversation!.messageCount).to.equal(countBefore);
});

it('should reject when both reply and update are provided', async () => {
it('should reject when both reply and edit are provided', async () => {
const conversationId = await seedConversation(ctx);

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
reply: { text: 'a' },
update: { text: 'b' },
edit: { messageId: 'platform-msg-1', content: { text: 'b' } },
});

expect(res.status).to.equal(400);
});

it('should reject when edit is combined with signals', async () => {
const conversationId = await seedConversation(ctx);

const res = await postReply({
conversationId,
integrationIdentifier: ctx.integrationIdentifier,
edit: { messageId: 'platform-msg-1', content: { text: 'b' } },
signals: [{ type: 'metadata', key: 'k', value: 'v' }],
});

expect(res.status).to.equal(400);
Expand Down Expand Up @@ -146,7 +192,7 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});

expect(res.status).to.equal(200);
expect(res.body.data.status).to.equal('ok');
expect(res.body.data).to.be.null;

const conversation = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
Expand Down Expand Up @@ -195,7 +241,7 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});

expect(res.status).to.equal(200);
expect(res.body.data.status).to.equal('ok');
expect(res.body.data).to.be.null;

const conversation = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
Expand Down Expand Up @@ -237,7 +283,8 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
});

expect(res.status).to.equal(200);
expect(res.body.data.status).to.equal('ok');
expect(res.body.data.messageId).to.equal('platform-msg-1');
expect(res.body.data.platformThreadId).to.equal('platform-thread-1');

const convAfter = await conversationRepository.findOne(
{ _id: conversationId, _environmentId: ctx.session.environment._id },
Expand Down
50 changes: 45 additions & 5 deletions apps/api/src/app/agents/services/chat-sdk.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BadRequestException, forwardRef, Inject, Injectable, OnModuleDestroy } from '@nestjs/common';
import { PinoLogger } from '@novu/application-generic';
import type { Chat, EmojiValue, Message, ReactionEvent, Thread } from 'chat';
import type { SentMessageInfo } from '@novu/framework';
import type { AdapterPostableMessage, Chat, EmojiValue, Message, ReactionEvent, Thread } from 'chat';
import { Request as ExpressRequest, Response as ExpressResponse } from 'express';
import { LRUCache } from 'lru-cache';
import { AgentEventEnum } from '../dtos/agent-event.enum';
Expand Down Expand Up @@ -105,7 +106,7 @@ export class ChatSdkService implements OnModuleDestroy {
platform: string,
serializedThread: Record<string, unknown>,
content: ReplyContentDto
): Promise<void> {
): Promise<SentMessageInfo> {
const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier);
const instanceKey = `${agentId}:${integrationIdentifier}`;
const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config);
Expand All @@ -114,13 +115,52 @@ export class ChatSdkService implements OnModuleDestroy {
const adapter = chat.getAdapter(platform);
const thread = ThreadImpl.fromJSON(serializedThread, adapter);

let sent: { id: string; threadId: string };
if (content.card) {
sent = await thread.post(content.card);
} else if (content.markdown !== undefined) {
sent = await thread.post({ markdown: content.markdown, files: content.files });
} else {
sent = await thread.post(content.text ?? '');
}

return { messageId: sent.id, platformThreadId: sent.threadId };
}

async editInConversation(
agentId: string,
integrationIdentifier: string,
platform: string,
platformThreadId: string,
platformMessageId: string,
content: ReplyContentDto
): Promise<SentMessageInfo> {
const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier);
const instanceKey = `${agentId}:${integrationIdentifier}`;
const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config);

const adapter = chat.getAdapter(platform);
if (typeof adapter.editMessage !== 'function') {
throw new BadRequestException(`Platform ${platform} does not support editing messages`);
}

let edited: { id: string; threadId: string };
if (content.card) {
await thread.post(content.card);
edited = await adapter.editMessage(
platformThreadId,
platformMessageId,
content.card as unknown as AdapterPostableMessage
);
} else if (content.markdown !== undefined) {
await thread.post({ markdown: content.markdown, files: content.files });
edited = await adapter.editMessage(platformThreadId, platformMessageId, {
markdown: content.markdown,
files: content.files,
} as unknown as AdapterPostableMessage);
} else {
await thread.post(content.text ?? '');
edited = await adapter.editMessage(platformThreadId, platformMessageId, content.text ?? '');
}

return { messageId: edited.id, platformThreadId: edited.threadId };
}

async removeReaction(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 { ReplyContentDto } from '../../dtos/agent-reply-payload.dto';
import { EditPayloadDto, ReplyContentDto } from '../../dtos/agent-reply-payload.dto';

export type { Signal } from '@novu/framework';

Expand All @@ -26,8 +26,8 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand {

@IsOptional()
@ValidateNested()
@Type(() => ReplyContentDto)
update?: ReplyContentDto;
@Type(() => EditPayloadDto)
edit?: EditPayloadDto;

@IsOptional()
@IsObject()
Expand Down
Loading
Loading