diff --git a/apps/api/src/app/agents/agents.module.ts b/apps/api/src/app/agents/agents.module.ts index f5a2b098b17..d4fb08a5ea7 100644 --- a/apps/api/src/app/agents/agents.module.ts +++ b/apps/api/src/app/agents/agents.module.ts @@ -11,7 +11,7 @@ import { SharedModule } from '../shared/shared.module'; import { AgentsController } from './agents.controller'; import { AgentsWebhookController } from './agents-webhook.controller'; import { AgentConversationService } from './services/agent-conversation.service'; -import { AgentCredentialService } from './services/agent-credential.service'; +import { AgentConfigResolver } from './services/agent-config-resolver.service'; import { AgentInboundHandler } from './services/agent-inbound-handler.service'; import { AgentSubscriberResolver } from './services/agent-subscriber-resolver.service'; import { BridgeExecutorService } from './services/bridge-executor.service'; @@ -27,7 +27,7 @@ import { USE_CASES } from './usecases'; ChannelEndpointRepository, ConversationRepository, ConversationActivityRepository, - AgentCredentialService, + AgentConfigResolver, AgentSubscriberResolver, AgentConversationService, AgentInboundHandler, diff --git a/apps/api/src/app/agents/dtos/agent-behavior.dto.ts b/apps/api/src/app/agents/dtos/agent-behavior.dto.ts index e2b0cd33395..b9e1444a1c9 100644 --- a/apps/api/src/app/agents/dtos/agent-behavior.dto.ts +++ b/apps/api/src/app/agents/dtos/agent-behavior.dto.ts @@ -1,9 +1,36 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsBoolean, IsOptional } from 'class-validator'; +import { IsBoolean, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class AgentReactionSettingsDto { + @ApiPropertyOptional({ + description: 'Emoji reaction for incoming messages. Emoji name string to customize, null to disable. Default: "eyes" (👀)', + default: 'eyes', + }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsString() + onMessageReceived?: string | null; + + @ApiPropertyOptional({ + description: 'Emoji reaction when a conversation is resolved. Emoji name string to customize, null to disable. Default: "check" (✅)', + default: 'check', + }) + @IsOptional() + @ValidateIf((_, value) => value !== null) + @IsString() + onResolved?: string | null; +} export class AgentBehaviorDto { @ApiPropertyOptional({ description: 'Show a "Thinking..." indicator while the agent is processing a message' }) @IsBoolean() @IsOptional() thinkingIndicatorEnabled?: boolean; + + @ApiPropertyOptional({ type: AgentReactionSettingsDto, description: 'Automatic emoji reactions on messages' }) + @ValidateNested() + @Type(() => AgentReactionSettingsDto) + @IsOptional() + reactions?: AgentReactionSettingsDto; } 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 6e69d2e6082..7f67eaecf80 100644 --- a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts @@ -35,6 +35,8 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { const chatSdkService = testServer.getService(ChatSdkService); sinon.stub(chatSdkService, 'postToConversation').resolves(); + sinon.stub(chatSdkService, 'reactToMessage').resolves(); + sinon.stub(chatSdkService, 'removeReaction').resolves(); }); function postReply(body: Record) { 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 106d56292ff..a236113d679 100644 --- a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts +++ b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts @@ -9,7 +9,7 @@ import { expect } from 'chai'; import sinon from 'sinon'; import { AgentInboundHandler } from '../services/agent-inbound-handler.service'; import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service'; -import { AgentCredentialService } from '../services/agent-credential.service'; +import { AgentConfigResolver } from '../services/agent-config-resolver.service'; import { AgentEventEnum } from '../dtos/agent-event.enum'; import { setupAgentTestContext, @@ -20,6 +20,15 @@ import { } from './helpers/agent-test-setup'; import { signSlackRequest, buildSlackChallenge } from './helpers/providers/slack'; +function mockSentMessage() { + return { + addReaction: async () => {}, + removeReaction: async () => {}, + edit: async () => mockSentMessage(), + delete: async () => {}, + }; +} + function mockThread(id: string, channelId = 'C_TEST') { return { id, @@ -28,6 +37,7 @@ function mockThread(id: string, channelId = 'C_TEST') { startTyping: async () => {}, subscribe: async () => {}, toJSON: () => ({ id, platform: 'slack', channelId, serialized: true }), + createSentMessageFromMessage: () => mockSentMessage(), }; } @@ -48,7 +58,7 @@ function mockMessage(opts: { id?: string; userId: string; text: string; fullName describe('Agent Webhook - inbound flow #novu-v2', () => { let ctx: AgentTestContext; let inboundHandler: AgentInboundHandler; - let credentialService: AgentCredentialService; + let configResolver: AgentConfigResolver; let bridgeCalls: BridgeExecutorParams[]; before(() => { @@ -58,7 +68,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => { beforeEach(async () => { ctx = await setupAgentTestContext(); inboundHandler = testServer.getService(AgentInboundHandler); - credentialService = testServer.getService(AgentCredentialService); + configResolver = testServer.getService(AgentConfigResolver); bridgeCalls = []; const bridgeExecutor = testServer.getService(BridgeExecutorService); @@ -68,7 +78,7 @@ describe('Agent Webhook - inbound flow #novu-v2', () => { }); async function invokeInbound(threadId: string, message: ReturnType, event = AgentEventEnum.ON_MESSAGE) { - const config = await credentialService.resolve(ctx.agentId, ctx.integrationIdentifier); + const config = await configResolver.resolve(ctx.agentId, ctx.integrationIdentifier); const thread = mockThread(threadId); await inboundHandler.handle(ctx.agentId, config, thread as any, message as any, event); } diff --git a/apps/api/src/app/agents/e2e/agents.e2e.ts b/apps/api/src/app/agents/e2e/agents.e2e.ts index ecd03161018..b6da2ca4f64 100644 --- a/apps/api/src/app/agents/e2e/agents.e2e.ts +++ b/apps/api/src/app/agents/e2e/agents.e2e.ts @@ -98,6 +98,46 @@ describe('Agents API - /agents #novu-v2', () => { await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); }); + it('should update and return agent reaction settings with defaults', async () => { + const identifier = `e2e-reactions-${Date.now()}`; + + const createRes = await session.testAgent.post('/v1/agents').send({ + name: 'Reaction Agent', + identifier, + }); + + expect(createRes.status).to.equal(201); + expect(createRes.body.data.behavior).to.equal(undefined); + + const setReactionsRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + behavior: { + reactions: { onMessageReceived: 'wave', onResolved: 'thumbs_up' }, + }, + }); + + expect(setReactionsRes.status).to.equal(200); + expect(setReactionsRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave'); + expect(setReactionsRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up'); + + const getRes = await session.testAgent.get(`/v1/agents/${encodeURIComponent(identifier)}`); + + expect(getRes.status).to.equal(200); + expect(getRes.body.data.behavior.reactions.onMessageReceived).to.equal('wave'); + expect(getRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up'); + + const disableRes = await session.testAgent.patch(`/v1/agents/${encodeURIComponent(identifier)}`).send({ + behavior: { + reactions: { onMessageReceived: null }, + }, + }); + + expect(disableRes.status).to.equal(200); + expect(disableRes.body.data.behavior.reactions.onMessageReceived).to.equal(null); + expect(disableRes.body.data.behavior.reactions.onResolved).to.equal('thumbs_up'); + + await session.testAgent.delete(`/v1/agents/${encodeURIComponent(identifier)}`); + }); + it('should return 422 when identifier is not a valid slug', async () => { const res = await session.testAgent.post('/v1/agents').send({ name: 'Invalid Slug Agent', diff --git a/apps/api/src/app/agents/services/agent-credential.service.ts b/apps/api/src/app/agents/services/agent-config-resolver.service.ts similarity index 83% rename from apps/api/src/app/agents/services/agent-credential.service.ts rename to apps/api/src/app/agents/services/agent-config-resolver.service.ts index 25b03861aec..ed41b54eb88 100644 --- a/apps/api/src/app/agents/services/agent-credential.service.ts +++ b/apps/api/src/app/agents/services/agent-config-resolver.service.ts @@ -11,7 +11,7 @@ import { FeatureFlagsKeysEnum } from '@novu/shared'; import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; import { resolveAgentPlatform } from '../utils/provider-to-platform'; -export interface ResolvedPlatformConfig { +export interface ResolvedAgentConfig { platform: AgentPlatformEnum; credentials: ICredentialsEntity; connectionAccessToken?: string; @@ -21,14 +21,26 @@ export interface ResolvedPlatformConfig { integrationIdentifier: string; integrationId: string; thinkingIndicatorEnabled: boolean; + reactionOnMessageReceived: string | null; + reactionOnResolved: string | null; } +const DEFAULT_REACTION_ON_MESSAGE = 'eyes'; +const DEFAULT_REACTION_ON_RESOLVED = 'check'; + function resolveThinkingIndicator(agent: { behavior?: { thinkingIndicatorEnabled?: boolean } }): boolean { return agent.behavior?.thinkingIndicatorEnabled !== false; } +function resolveReaction(value: string | null | undefined, defaultEmoji: string): string | null { + if (value === null) return null; + if (value === undefined) return defaultEmoji; + + return value; +} + @Injectable() -export class AgentCredentialService { +export class AgentConfigResolver { constructor( private readonly featureFlagsService: FeatureFlagsService, private readonly agentRepository: AgentRepository, @@ -37,7 +49,7 @@ export class AgentCredentialService { private readonly channelConnectionRepository: ChannelConnectionRepository ) {} - async resolve(agentId: string, integrationIdentifier: string): Promise { + async resolve(agentId: string, integrationIdentifier: string): Promise { const agent = await this.agentRepository.findByIdForWebhook(agentId); if (!agent) { throw new NotFoundException(`Agent ${agentId} not found`); @@ -106,6 +118,11 @@ export class AgentCredentialService { integrationIdentifier, integrationId: integration._id, thinkingIndicatorEnabled: resolveThinkingIndicator(agent), + reactionOnMessageReceived: resolveReaction( + agent.behavior?.reactions?.onMessageReceived, + DEFAULT_REACTION_ON_MESSAGE + ), + reactionOnResolved: resolveReaction(agent.behavior?.reactions?.onResolved, DEFAULT_REACTION_ON_RESOLVED), }; } } 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 d3e75b8bfb8..8868caa39d3 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 @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; import { PinoLogger } from '@novu/application-generic'; -import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, SubscriberRepository } from '@novu/dal'; +import { ConversationActivitySenderTypeEnum, ConversationParticipantTypeEnum, ConversationRepository, SubscriberRepository } from '@novu/dal'; import type { Message, Thread } from 'chat'; import { AgentEventEnum } from '../dtos/agent-event.enum'; +import { ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentConversationService } from './agent-conversation.service'; -import { ResolvedPlatformConfig } from './agent-credential.service'; import { AgentSubscriberResolver } from './agent-subscriber-resolver.service'; import { type BridgeAction, BridgeExecutorService } from './bridge-executor.service'; @@ -14,13 +14,14 @@ export class AgentInboundHandler { private readonly logger: PinoLogger, private readonly subscriberResolver: AgentSubscriberResolver, private readonly conversationService: AgentConversationService, + private readonly conversationRepository: ConversationRepository, private readonly bridgeExecutor: BridgeExecutorService, private readonly subscriberRepository: SubscriberRepository ) {} async handle( agentId: string, - config: ResolvedPlatformConfig, + config: ResolvedAgentConfig, thread: Thread, message: Message, event: AgentEventEnum @@ -75,6 +76,21 @@ export class AgentInboundHandler { organizationId: config.organizationId, }); + const channel = conversation.channels[0]; + const isFirstMessage = !channel?.firstPlatformMessageId; + + if (isFirstMessage && config.reactionOnMessageReceived && message.id) { + thread.createSentMessageFromMessage(message).addReaction(config.reactionOnMessageReceived).catch((err) => { + this.logger.warn(err, `[agent:${agentId}] Failed to add ack reaction to first message`); + }); + + this.conversationRepository + .setFirstPlatformMessageId(config.environmentId, config.organizationId, conversation._id, thread.id, message.id) + .catch((err) => { + this.logger.warn(err, `[agent:${agentId}] Failed to store firstPlatformMessageId`); + }); + } + if (config.thinkingIndicatorEnabled) { await thread.startTyping('Thinking...'); } @@ -112,7 +128,7 @@ export class AgentInboundHandler { async handleAction( agentId: string, - config: ResolvedPlatformConfig, + config: ResolvedAgentConfig, thread: Thread, action: BridgeAction, userId: string 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 362ea6bcfbf..92408b5f2b2 100644 --- a/apps/api/src/app/agents/services/bridge-executor.service.ts +++ b/apps/api/src/app/agents/services/bridge-executor.service.ts @@ -15,7 +15,7 @@ import { } from '@novu/dal'; import type { Message } from 'chat'; import { AgentEventEnum } from '../dtos/agent-event.enum'; -import { ResolvedPlatformConfig } from './agent-credential.service'; +import { ResolvedAgentConfig } from './agent-config-resolver.service'; const MAX_RETRIES = 2; const RETRY_BASE_DELAY_MS = 500; @@ -28,7 +28,7 @@ export interface BridgePlatformContext { export interface BridgeExecutorParams { event: AgentEventEnum; - config: ResolvedPlatformConfig; + config: ResolvedAgentConfig; conversation: ConversationEntity; subscriber: SubscriberEntity | null; history: ConversationActivityEntity[]; 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 7fa5f275325..332a883dce9 100644 --- a/apps/api/src/app/agents/services/chat-sdk.service.ts +++ b/apps/api/src/app/agents/services/chat-sdk.service.ts @@ -7,7 +7,7 @@ import { AgentEventEnum } from '../dtos/agent-event.enum'; import { AgentPlatformEnum } from '../dtos/agent-platform.enum'; import type { ReplyContentDto } from '../dtos/agent-reply-payload.dto'; import { sendWebResponse, toWebRequest } from '../utils/express-to-web-request'; -import { AgentCredentialService, ResolvedPlatformConfig } from './agent-credential.service'; +import { AgentConfigResolver, ResolvedAgentConfig } from './agent-config-resolver.service'; import { AgentInboundHandler } from './agent-inbound-handler.service'; /** @@ -41,7 +41,7 @@ export class ChatSdkService implements OnModuleDestroy { constructor( private readonly logger: PinoLogger, - private readonly agentCredentialService: AgentCredentialService, + private readonly agentConfigResolver: AgentConfigResolver, private readonly inboundHandler: AgentInboundHandler ) { this.instances = new LRUCache({ @@ -56,7 +56,7 @@ export class ChatSdkService implements OnModuleDestroy { } async handleWebhook(agentId: string, integrationIdentifier: string, req: ExpressRequest, res: ExpressResponse) { - const config = await this.agentCredentialService.resolve(agentId, integrationIdentifier); + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); const { platform } = config; const instanceKey = `${agentId}:${integrationIdentifier}`; @@ -103,7 +103,7 @@ export class ChatSdkService implements OnModuleDestroy { serializedThread: Record, content: ReplyContentDto ): Promise { - const config = await this.agentCredentialService.resolve(agentId, integrationIdentifier); + const config = await this.agentConfigResolver.resolve(agentId, integrationIdentifier); const instanceKey = `${agentId}:${integrationIdentifier}`; const chat = await this.getOrCreate(instanceKey, agentId, config.platform, config); @@ -120,11 +120,43 @@ export class ChatSdkService implements OnModuleDestroy { } } + async removeReaction( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + emoji: string + ): Promise { + 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); + await adapter.removeReaction(platformThreadId, platformMessageId, emoji); + } + + async reactToMessage( + agentId: string, + integrationIdentifier: string, + platform: string, + platformThreadId: string, + platformMessageId: string, + emoji: string + ): Promise { + 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); + await adapter.addReaction(platformThreadId, platformMessageId, emoji); + } + private async getOrCreate( instanceKey: string, agentId: string, platform: AgentPlatformEnum, - config: ResolvedPlatformConfig + config: ResolvedAgentConfig ): Promise { const existing = this.instances.get(instanceKey); if (existing) return existing; @@ -146,7 +178,7 @@ export class ChatSdkService implements OnModuleDestroy { instanceKey: string, agentId: string, platform: AgentPlatformEnum, - config: ResolvedPlatformConfig + config: ResolvedAgentConfig ): Promise { const chat = await this.createChatInstance(instanceKey, platform, config); this.registerEventHandlers(agentId, chat, config); @@ -158,7 +190,7 @@ export class ChatSdkService implements OnModuleDestroy { private async createChatInstance( instanceKey: string, platform: AgentPlatformEnum, - config: ResolvedPlatformConfig + config: ResolvedAgentConfig ): Promise { const [{ Chat }, { createRedisState }] = await Promise.all([ esmImport('chat'), @@ -185,7 +217,7 @@ export class ChatSdkService implements OnModuleDestroy { private async buildAdapters( platform: AgentPlatformEnum, - config: ResolvedPlatformConfig + config: ResolvedAgentConfig ): Promise> { const { credentials, connectionAccessToken } = config; @@ -228,7 +260,7 @@ export class ChatSdkService implements OnModuleDestroy { } } - private registerEventHandlers(agentId: string, chat: Chat, config: ResolvedPlatformConfig) { + private registerEventHandlers(agentId: string, chat: Chat, config: ResolvedAgentConfig) { chat.onNewMention(async (thread: Thread, message: Message) => { try { await thread.subscribe(); 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 a893b3b5a58..a34351eff81 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 @@ -10,7 +10,7 @@ import { SubscriberRepository, } from '@novu/dal'; import { AgentEventEnum } from '../../dtos/agent-event.enum'; -import { AgentCredentialService } from '../../services/agent-credential.service'; +import { AgentConfigResolver } from '../../services/agent-config-resolver.service'; import { AgentConversationService } from '../../services/agent-conversation.service'; import { BridgeExecutorService } from '../../services/bridge-executor.service'; import { ChatSdkService } from '../../services/chat-sdk.service'; @@ -25,7 +25,7 @@ export class HandleAgentReply { private readonly subscriberRepository: SubscriberRepository, private readonly chatSdkService: ChatSdkService, private readonly bridgeExecutor: BridgeExecutorService, - private readonly agentCredentialService: AgentCredentialService, + private readonly agentConfigResolver: AgentConfigResolver, private readonly conversationService: AgentConversationService, private readonly logger: PinoLogger ) {} @@ -60,6 +60,10 @@ export class HandleAgentReply { if (command.reply) { await this.deliverMessage(command, conversation, channel, command.reply, ConversationActivityTypeEnum.MESSAGE); + + this.removeAckReaction(command, conversation, channel).catch((err) => { + this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to remove ack reaction`); + }); } if (command.signals?.length) { @@ -218,16 +222,62 @@ export class HandleAgentReply { }), ]); + this.reactOnResolve(command, conversation, channel).catch((err) => { + this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to add resolve reaction`); + }); + this.fireOnResolveBridgeCall(command, conversation).catch((err) => { this.logger.error(err, `[agent:${command.agentIdentifier}] Failed to fire onResolve bridge call`); }); } + private async removeAckReaction( + command: HandleAgentReplyCommand, + conversation: ConversationEntity, + channel: ConversationChannel + ): Promise { + const firstMessageId = channel.firstPlatformMessageId; + if (!firstMessageId) return; + + const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); + if (!config.reactionOnMessageReceived) return; + + await this.chatSdkService.removeReaction( + conversation._agentId, + command.integrationIdentifier, + channel.platform, + channel.platformThreadId, + firstMessageId, + config.reactionOnMessageReceived + ); + } + + private async reactOnResolve( + command: HandleAgentReplyCommand, + conversation: ConversationEntity, + channel: ConversationChannel + ): Promise { + const firstMessageId = channel.firstPlatformMessageId; + if (!firstMessageId) return; + + const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); + if (!config.reactionOnResolved) return; + + await this.chatSdkService.reactToMessage( + conversation._agentId, + command.integrationIdentifier, + channel.platform, + channel.platformThreadId, + firstMessageId, + config.reactionOnResolved + ); + } + private async fireOnResolveBridgeCall( command: HandleAgentReplyCommand, conversation: ConversationEntity ): Promise { - const config = await this.agentCredentialService.resolve(conversation._agentId, command.integrationIdentifier); + const config = await this.agentConfigResolver.resolve(conversation._agentId, command.integrationIdentifier); const subscriberParticipant = conversation.participants.find((p) => p.type === 'subscriber'); const [subscriber, history] = await Promise.all([ diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts index 0ed25e1a5a4..f1dc84eb896 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts @@ -41,6 +41,15 @@ export class UpdateAgent { if (command.behavior.thinkingIndicatorEnabled !== undefined) { $set['behavior.thinkingIndicatorEnabled'] = command.behavior.thinkingIndicatorEnabled; } + + if (command.behavior.reactions !== undefined) { + if (command.behavior.reactions.onMessageReceived !== undefined) { + $set['behavior.reactions.onMessageReceived'] = command.behavior.reactions.onMessageReceived; + } + if (command.behavior.reactions.onResolved !== undefined) { + $set['behavior.reactions.onResolved'] = command.behavior.reactions.onResolved; + } + } } await this.agentRepository.updateOne( diff --git a/libs/dal/src/repositories/agent/agent.entity.ts b/libs/dal/src/repositories/agent/agent.entity.ts index 0b376755aed..08d425401c3 100644 --- a/libs/dal/src/repositories/agent/agent.entity.ts +++ b/libs/dal/src/repositories/agent/agent.entity.ts @@ -2,8 +2,16 @@ import type { ChangePropsValueType } from '../../types/helpers'; import type { EnvironmentId } from '../environment'; import type { OrganizationId } from '../organization'; +export interface AgentReactionSettings { + /** Emoji name for acknowledging incoming messages (null = disabled, undefined = default "eyes") */ + onMessageReceived?: string | null; + /** Emoji name for resolved conversations (null = disabled, undefined = default "check") */ + onResolved?: string | null; +} + export interface AgentBehavior { thinkingIndicatorEnabled?: boolean; + reactions?: AgentReactionSettings; } export class AgentEntity { diff --git a/libs/dal/src/repositories/agent/agent.schema.ts b/libs/dal/src/repositories/agent/agent.schema.ts index 908ebf37f94..3648e39ee10 100644 --- a/libs/dal/src/repositories/agent/agent.schema.ts +++ b/libs/dal/src/repositories/agent/agent.schema.ts @@ -16,6 +16,10 @@ const agentSchema = new Schema( description: Schema.Types.String, behavior: { thinkingIndicatorEnabled: Schema.Types.Boolean, + reactions: { + onMessageReceived: Schema.Types.Mixed, + onResolved: Schema.Types.Mixed, + }, }, _organizationId: { type: Schema.Types.ObjectId, diff --git a/libs/dal/src/repositories/conversation/conversation.entity.ts b/libs/dal/src/repositories/conversation/conversation.entity.ts index 21651dd6520..702855323d3 100644 --- a/libs/dal/src/repositories/conversation/conversation.entity.ts +++ b/libs/dal/src/repositories/conversation/conversation.entity.ts @@ -28,6 +28,8 @@ export interface ConversationChannel { platformThreadId: string; /** Chat SDK SerializedThread — stored for reply delivery via ThreadImpl.fromJSON() */ serializedThread?: Record; + /** Platform message ID of the thread-starting message */ + firstPlatformMessageId?: string; } export class ConversationEntity { diff --git a/libs/dal/src/repositories/conversation/conversation.repository.ts b/libs/dal/src/repositories/conversation/conversation.repository.ts index 4ae15f70de1..028b37df5ef 100644 --- a/libs/dal/src/repositories/conversation/conversation.repository.ts +++ b/libs/dal/src/repositories/conversation/conversation.repository.ts @@ -108,4 +108,22 @@ export class ConversationRepository extends BaseRepositoryV2< { $set: { 'channels.$.serializedThread': serializedThread } } ); } + + async setFirstPlatformMessageId( + environmentId: string, + organizationId: string, + id: string, + platformThreadId: string, + firstPlatformMessageId: string + ): Promise { + await this.update( + { + _id: id, + _environmentId: environmentId, + _organizationId: organizationId, + 'channels.platformThreadId': platformThreadId, + }, + { $set: { 'channels.$.firstPlatformMessageId': firstPlatformMessageId } } + ); + } } diff --git a/libs/dal/src/repositories/conversation/conversation.schema.ts b/libs/dal/src/repositories/conversation/conversation.schema.ts index b46d77d6774..4bc369be7cb 100644 --- a/libs/dal/src/repositories/conversation/conversation.schema.ts +++ b/libs/dal/src/repositories/conversation/conversation.schema.ts @@ -51,6 +51,9 @@ const conversationSchema = new Schema( serializedThread: { type: Schema.Types.Mixed, }, + firstPlatformMessageId: { + type: Schema.Types.String, + }, }, { _id: false } ),