diff --git a/apps/api/src/app/agents/e2e/agent-reply.e2e.ts b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts new file mode 100644 index 00000000000..6e69d2e6082 --- /dev/null +++ b/apps/api/src/app/agents/e2e/agent-reply.e2e.ts @@ -0,0 +1,270 @@ +import { + ConversationActivitySenderTypeEnum, + ConversationActivityTypeEnum, + ConversationStatusEnum, +} from '@novu/dal'; +import { testServer } from '@novu/testing'; +import { expect } from 'chai'; +import sinon from 'sinon'; +import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service'; +import { ChatSdkService } from '../services/chat-sdk.service'; +import { + setupAgentTestContext, + seedConversation, + conversationRepository, + activityRepository, + AgentTestContext, +} from './helpers/agent-test-setup'; + +describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => { + let ctx: AgentTestContext; + let bridgeCalls: BridgeExecutorParams[]; + + before(() => { + process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true'; + }); + + beforeEach(async () => { + ctx = await setupAgentTestContext(); + + bridgeCalls = []; + const bridgeExecutor = testServer.getService(BridgeExecutorService); + sinon.stub(bridgeExecutor, 'execute').callsFake(async (params: BridgeExecutorParams) => { + bridgeCalls.push(params); + }); + + const chatSdkService = testServer.getService(ChatSdkService); + sinon.stub(chatSdkService, 'postToConversation').resolves(); + }); + + function postReply(body: Record) { + return ctx.session.testAgent + .post(`/v1/agents/${ctx.agentIdentifier}/reply`) + .send(body); + } + + describe('Delivery and persistence', () => { + it('should persist agent reply activity and increment messageCount', 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, + reply: { text: 'Hello from agent' }, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.status).to.equal('ok'); + + const convAfter = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + expect(convAfter!.messageCount).to.equal(countBefore + 1); + expect(convAfter!.lastMessagePreview).to.equal('Hello from agent'); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversationId + ); + const agentActivity = activities.find( + (a) => a.senderType === ConversationActivitySenderTypeEnum.AGENT && a.type === ConversationActivityTypeEnum.MESSAGE + ); + expect(agentActivity).to.exist; + expect(agentActivity!.content).to.equal('Hello from agent'); + }); + + it('should persist update activity and return early without executing resolve', async () => { + const conversationId = await seedConversation(ctx); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + update: { text: 'Processing...' }, + resolve: { summary: 'Should be ignored' }, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.status).to.equal('update_sent'); + + 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 conversation = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + expect(conversation!.status).to.equal(ConversationStatusEnum.ACTIVE); + }); + + it('should reject when both reply and update are provided', async () => { + const conversationId = await seedConversation(ctx); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + reply: { text: 'a' }, + update: { text: 'b' }, + }); + + expect(res.status).to.equal(400); + }); + + it('should return 400 when conversation has no serialized thread', async () => { + const conversationId = await seedConversation(ctx, { withSerializedThread: false }); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + reply: { text: 'Should fail' }, + }); + + expect(res.status).to.equal(400); + }); + }); + + describe('Signals (metadata)', () => { + it('should merge metadata signals into conversation.metadata and persist signal activity', async () => { + const conversationId = await seedConversation(ctx); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + signals: [{ type: 'metadata', key: 'sentiment', value: 'positive' }], + }); + + expect(res.status).to.equal(200); + expect(res.body.data.status).to.equal('ok'); + + const conversation = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + expect(conversation!.metadata).to.have.property('sentiment', 'positive'); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversationId + ); + const signalActivity = activities.find( + (a) => + a.type === ConversationActivityTypeEnum.SIGNAL && + a.senderType === ConversationActivitySenderTypeEnum.SYSTEM + ); + expect(signalActivity).to.exist; + expect(signalActivity!.signalData).to.exist; + expect(signalActivity!.signalData!.type).to.equal('metadata'); + }); + + it('should reject when cumulative metadata exceeds 64KB', async () => { + const bigValue = 'x'.repeat(60_000); + const conversationId = await seedConversation(ctx, { + metadata: { existingBigKey: bigValue }, + }); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + signals: [{ type: 'metadata', key: 'overflow', value: 'x'.repeat(6_000) }], + }); + + expect(res.status).to.equal(400); + }); + }); + + describe('Resolve', () => { + it('should resolve conversation and fire onResolve bridge callback', async () => { + const conversationId = await seedConversation(ctx); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + resolve: { summary: 'Issue fixed' }, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.status).to.equal('ok'); + + const conversation = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + expect(conversation!.status).to.equal(ConversationStatusEnum.RESOLVED); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversationId + ); + const resolveActivity = activities.find( + (a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'resolve' + ); + expect(resolveActivity).to.exist; + expect(resolveActivity!.content).to.contain('Issue fixed'); + + // onResolve bridge call is fire-and-forget; give it a moment + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(bridgeCalls.length).to.be.gte(1); + const resolveCall = bridgeCalls.find((c) => c.event === 'onResolve'); + expect(resolveCall).to.exist; + }); + + it('should handle reply + signals + resolve in a single request', async () => { + const conversationId = await seedConversation(ctx); + const convBefore = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + + const res = await postReply({ + conversationId, + integrationIdentifier: ctx.integrationIdentifier, + reply: { text: 'Here is your answer' }, + signals: [{ type: 'metadata', key: 'resolved_by', value: 'bot' }], + resolve: { summary: 'Answered' }, + }); + + expect(res.status).to.equal(200); + expect(res.body.data.status).to.equal('ok'); + + const convAfter = await conversationRepository.findOne( + { _id: conversationId, _environmentId: ctx.session.environment._id }, + '*' + ); + expect(convAfter!.messageCount).to.equal(convBefore!.messageCount + 1); + expect(convAfter!.metadata).to.have.property('resolved_by', 'bot'); + expect(convAfter!.status).to.equal(ConversationStatusEnum.RESOLVED); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversationId + ); + + const messageActivity = activities.find( + (a) => a.type === ConversationActivityTypeEnum.MESSAGE && a.senderType === ConversationActivitySenderTypeEnum.AGENT + ); + expect(messageActivity).to.exist; + expect(messageActivity!.content).to.equal('Here is your answer'); + + const metadataActivity = activities.find( + (a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'metadata' + ); + expect(metadataActivity).to.exist; + + const resolveActivity = activities.find( + (a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'resolve' + ); + expect(resolveActivity).to.exist; + }); + }); +}); diff --git a/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts new file mode 100644 index 00000000000..fc1db6039c2 --- /dev/null +++ b/apps/api/src/app/agents/e2e/agent-webhook.e2e.ts @@ -0,0 +1,352 @@ +import { + ConversationActivitySenderTypeEnum, + ConversationParticipantTypeEnum, + ConversationStatusEnum, + SubscriberRepository, +} from '@novu/dal'; +import { testServer } from '@novu/testing'; +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 { AgentEventEnum } from '../dtos/agent-event.enum'; +import { + setupAgentTestContext, + seedChannelEndpoint, + conversationRepository, + activityRepository, + AgentTestContext, +} from './helpers/agent-test-setup'; +import { signSlackRequest, buildSlackChallenge } from './helpers/providers/slack'; + +function mockThread(id: string, channelId = 'C_TEST') { + return { + id, + channelId, + isDM: false, + startTyping: async () => {}, + subscribe: async () => {}, + toJSON: () => ({ id, platform: 'slack', channelId, serialized: true }), + }; +} + +function mockMessage(opts: { id?: string; userId: string; text: string; fullName?: string }) { + return { + id: opts.id ?? `msg-${Date.now()}`, + text: opts.text, + author: { + userId: opts.userId, + fullName: opts.fullName ?? 'Test User', + userName: 'testuser', + isBot: false, + }, + metadata: { dateSent: new Date() }, + }; +} + +describe('Agent Webhook - inbound flow #novu-v2', () => { + let ctx: AgentTestContext; + let inboundHandler: AgentInboundHandler; + let credentialService: AgentCredentialService; + let bridgeCalls: BridgeExecutorParams[]; + + before(() => { + process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true'; + }); + + beforeEach(async () => { + ctx = await setupAgentTestContext(); + inboundHandler = testServer.getService(AgentInboundHandler); + credentialService = testServer.getService(AgentCredentialService); + + bridgeCalls = []; + const bridgeExecutor = testServer.getService(BridgeExecutorService); + sinon.stub(bridgeExecutor, 'execute').callsFake(async (params: BridgeExecutorParams) => { + bridgeCalls.push(params); + }); + }); + + async function invokeInbound(threadId: string, message: ReturnType, event = AgentEventEnum.ON_START) { + const config = await credentialService.resolve(ctx.agentId, ctx.integrationIdentifier); + const thread = mockThread(threadId); + await inboundHandler.handle(ctx.agentId, config, thread as any, message as any, event); + } + + describe('Slack challenge verification', () => { + it('should respond to Slack url_verification challenge', async () => { + const challenge = buildSlackChallenge('my-challenge-value'); + const body = JSON.stringify(challenge); + const timestamp = Math.floor(Date.now() / 1000); + const headers = signSlackRequest(ctx.signingSecret, timestamp, body); + + const res = await ctx.session.testAgent + .post(`/v1/agents/${ctx.agentId}/webhook/${ctx.integrationIdentifier}`) + .set(headers) + .set('content-type', 'application/json') + .send(body); + + expect(res.status).to.equal(200); + expect(res.text).to.contain('my-challenge-value'); + }); + }); + + describe('Conversation creation', () => { + it('should create a conversation on first inbound message with platform_user participant', async () => { + const threadId = `T_CREATE_${Date.now()}`; + const msg = mockMessage({ userId: 'U_CREATOR', text: 'Hello agent' }); + + await invokeInbound(threadId, msg); + + const conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + + expect(conversation).to.exist; + expect(conversation!.status).to.equal(ConversationStatusEnum.ACTIVE); + expect(conversation!.channels[0].platformThreadId).to.equal(threadId); + expect(conversation!.channels[0].serializedThread).to.exist; + expect(conversation!.messageCount).to.be.gte(1); + + const platformUserParticipant = conversation!.participants.find( + (p) => p.type === ConversationParticipantTypeEnum.PLATFORM_USER + ); + expect(platformUserParticipant).to.exist; + expect(platformUserParticipant!.id).to.equal('slack:U_CREATOR'); + + const agentParticipant = conversation!.participants.find( + (p) => p.type === ConversationParticipantTypeEnum.AGENT + ); + expect(agentParticipant).to.exist; + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversation!._id + ); + expect(activities.length).to.be.gte(1); + + const userActivity = activities.find((a) => a.senderType === ConversationActivitySenderTypeEnum.PLATFORM_USER); + expect(userActivity).to.exist; + expect(userActivity!.content).to.equal('Hello agent'); + }); + + it('should create participant as subscriber when channel endpoint exists', async () => { + const subscriberRepository = new SubscriberRepository(); + const subscriber = await subscriberRepository.create({ + subscriberId: `sub-e2e-${Date.now()}`, + firstName: 'E2E', + lastName: 'Subscriber', + _environmentId: ctx.session.environment._id, + _organizationId: ctx.session.organization._id, + }); + + await seedChannelEndpoint(ctx, 'U_LINKED', subscriber.subscriberId); + + const threadId = `T_SUB_${Date.now()}`; + const msg = mockMessage({ userId: 'U_LINKED', text: 'Hi from subscriber' }); + + await invokeInbound(threadId, msg); + + const conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + + expect(conversation).to.exist; + + const subParticipant = conversation!.participants.find( + (p) => p.type === ConversationParticipantTypeEnum.SUBSCRIBER + ); + expect(subParticipant).to.exist; + expect(subParticipant!.id).to.equal(subscriber.subscriberId); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversation!._id + ); + const userActivity = activities.find((a) => a.content === 'Hi from subscriber'); + expect(userActivity!.senderType).to.equal(ConversationActivitySenderTypeEnum.SUBSCRIBER); + }); + }); + + describe('Thread handling', () => { + it('should reuse existing conversation for messages in the same thread', async () => { + const threadId = `T_REUSE_${Date.now()}`; + + await invokeInbound(threadId, mockMessage({ userId: 'U1', text: 'First message' })); + await invokeInbound( + threadId, + mockMessage({ userId: 'U1', text: 'Second message' }), + AgentEventEnum.ON_MESSAGE + ); + + const conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + + expect(conversation).to.exist; + expect(conversation!.messageCount).to.be.gte(2); + + const activities = await activityRepository.findByConversation( + ctx.session.environment._id, + conversation!._id + ); + expect(activities.length).to.be.gte(2); + }); + }); + + describe('Bridge call verification', () => { + it('should fire bridge call with correct payload shape and subscriber data', async () => { + const subscriberRepository = new SubscriberRepository(); + const subscriber = await subscriberRepository.create({ + subscriberId: `sub-bridge-${Date.now()}`, + firstName: 'Bridge', + lastName: 'Test', + email: 'bridge@test.com', + _environmentId: ctx.session.environment._id, + _organizationId: ctx.session.organization._id, + }); + + await seedChannelEndpoint(ctx, 'U_BRIDGE', subscriber.subscriberId); + + const threadId = `T_BRIDGE_${Date.now()}`; + await invokeInbound(threadId, mockMessage({ userId: 'U_BRIDGE', text: 'Bridge test' })); + + expect(bridgeCalls.length).to.equal(1); + const call = bridgeCalls[0]; + + expect(call.event).to.equal(AgentEventEnum.ON_START); + expect(call.config.agentIdentifier).to.equal(ctx.agentIdentifier); + expect(call.config.integrationIdentifier).to.equal(ctx.integrationIdentifier); + expect(call.config.platform).to.equal('slack'); + expect(call.conversation).to.exist; + expect(call.conversation._id).to.be.a('string'); + + expect(call.subscriber).to.exist; + expect(call.subscriber!.subscriberId).to.equal(subscriber.subscriberId); + expect(call.subscriber!.firstName).to.equal('Bridge'); + expect(call.subscriber!.email).to.equal('bridge@test.com'); + + expect(call.history).to.be.an('array'); + expect(call.message).to.exist; + expect(call.message!.text).to.equal('Bridge test'); + + expect(call.platformContext.threadId).to.equal(threadId); + expect(call.platformContext.channelId).to.equal('C_TEST'); + expect(call.platformContext.isDM).to.equal(false); + }); + + it('should send null subscriber in bridge payload when unresolved', async () => { + const threadId = `T_NOSUB_${Date.now()}`; + await invokeInbound(threadId, mockMessage({ userId: 'U_UNKNOWN', text: 'No subscriber' })); + + expect(bridgeCalls.length).to.equal(1); + expect(bridgeCalls[0].subscriber).to.be.null; + }); + }); + + describe('Security', () => { + it('should reject requests with invalid Slack signature', async () => { + const body = JSON.stringify({ type: 'event_callback', event: { type: 'app_mention' } }); + const timestamp = Math.floor(Date.now() / 1000); + const headers = signSlackRequest('wrong-secret', timestamp, body); + + const res = await ctx.session.testAgent + .post(`/v1/agents/${ctx.agentId}/webhook/${ctx.integrationIdentifier}`) + .set(headers) + .set('content-type', 'application/json') + .send(body); + + expect(res.status).to.not.equal(200); + }); + }); + + describe('Conversation lifecycle', () => { + it('should reopen resolved conversation on new inbound message', async () => { + const threadId = `T_REOPEN_${Date.now()}`; + + await invokeInbound(threadId, mockMessage({ userId: 'U_REOPEN', text: 'Initial' })); + + const conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + expect(conversation!.status).to.equal(ConversationStatusEnum.ACTIVE); + + await conversationRepository.updateStatus( + ctx.session.environment._id, + ctx.session.organization._id, + conversation!._id, + ConversationStatusEnum.RESOLVED + ); + + await invokeInbound( + threadId, + mockMessage({ userId: 'U_REOPEN', text: 'Reopening' }), + AgentEventEnum.ON_MESSAGE + ); + + const reopened = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + expect(reopened!.status).to.equal(ConversationStatusEnum.ACTIVE); + expect(reopened!._id).to.equal(conversation!._id); + }); + + it('should upgrade platform_user to subscriber when endpoint is later created', async () => { + const subscriberRepository = new SubscriberRepository(); + const threadId = `T_UPGRADE_${Date.now()}`; + + await invokeInbound(threadId, mockMessage({ userId: 'U_LATER', text: 'Before endpoint' })); + + let conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + const platformUserParticipant = conversation!.participants.find( + (p) => p.type === ConversationParticipantTypeEnum.PLATFORM_USER + ); + expect(platformUserParticipant).to.exist; + + const subscriber = await subscriberRepository.create({ + subscriberId: `sub-upgrade-${Date.now()}`, + firstName: 'Upgraded', + _environmentId: ctx.session.environment._id, + _organizationId: ctx.session.organization._id, + }); + await seedChannelEndpoint(ctx, 'U_LATER', subscriber.subscriberId); + + await invokeInbound( + threadId, + mockMessage({ userId: 'U_LATER', text: 'After endpoint' }), + AgentEventEnum.ON_MESSAGE + ); + + conversation = await conversationRepository.findByPlatformThread( + ctx.session.environment._id, + ctx.session.organization._id, + threadId + ); + + const subParticipant = conversation!.participants.find( + (p) => p.type === ConversationParticipantTypeEnum.SUBSCRIBER + ); + expect(subParticipant).to.exist; + expect(subParticipant!.id).to.equal(subscriber.subscriberId); + + const remainingPlatformUsers = conversation!.participants.filter( + (p) => p.type === ConversationParticipantTypeEnum.PLATFORM_USER && p.id === 'slack:U_LATER' + ); + expect(remainingPlatformUsers.length).to.equal(0); + }); + }); +}); diff --git a/apps/api/src/app/agents/e2e/helpers/agent-test-setup.ts b/apps/api/src/app/agents/e2e/helpers/agent-test-setup.ts new file mode 100644 index 00000000000..022c0bd17e5 --- /dev/null +++ b/apps/api/src/app/agents/e2e/helpers/agent-test-setup.ts @@ -0,0 +1,139 @@ +import { + AgentIntegrationRepository, + AgentRepository, + ChannelConnectionRepository, + ChannelEndpointRepository, + ConversationActivityRepository, + ConversationRepository, + ConversationStatusEnum, + IntegrationRepository, +} from '@novu/dal'; +import { ChannelTypeEnum, ChatProviderIdEnum, ENDPOINT_TYPES } from '@novu/shared'; +import { UserSession } from '@novu/testing'; +import { encryptCredentials } from '@novu/application-generic'; + +const SIGNING_SECRET = 'test-slack-signing-secret'; +const BOT_TOKEN = 'xoxb-fake-bot-token-for-e2e'; + +export interface AgentTestContext { + session: UserSession; + agentId: string; + agentIdentifier: string; + integrationId: string; + integrationIdentifier: string; + signingSecret: string; +} + +export interface ReplyTestContext extends AgentTestContext { + conversationId: string; +} + +export const conversationRepository = new ConversationRepository(); +export const activityRepository = new ConversationActivityRepository(); +export const channelEndpointRepository = new ChannelEndpointRepository(); + +const integrationRepository = new IntegrationRepository(); +const agentIntegrationRepository = new AgentIntegrationRepository(); +const channelConnectionRepository = new ChannelConnectionRepository(); + +export async function setupAgentTestContext(): Promise { + const session = new UserSession(); + await session.initialize(); + + const agentIdentifier = `e2e-wh-agent-${Date.now()}`; + const createRes = await session.testAgent.post('/v1/agents').send({ + name: 'Webhook E2E Agent', + identifier: agentIdentifier, + }); + const agentId = createRes.body.data._id as string; + + const integration = await integrationRepository.create({ + _environmentId: session.environment._id, + _organizationId: session.organization._id, + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + credentials: encryptCredentials({ apiKey: SIGNING_SECRET }), + active: true, + name: 'Slack Agent E2E', + identifier: `slack-agent-e2e-${Date.now()}`, + priority: 1, + primary: false, + deleted: false, + }); + + await agentIntegrationRepository.create({ + _agentId: agentId, + _integrationId: integration._id, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + }); + + await channelConnectionRepository.create({ + identifier: `conn-e2e-${Date.now()}`, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + integrationIdentifier: integration.identifier, + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + contextKeys: [], + workspace: { id: 'W_TEAM', name: 'Test Workspace' }, + auth: { accessToken: BOT_TOKEN }, + }); + + return { + session, + agentId, + agentIdentifier, + integrationId: integration._id, + integrationIdentifier: integration.identifier, + signingSecret: SIGNING_SECRET, + }; +} + +export async function seedConversation( + ctx: AgentTestContext, + opts: { withSerializedThread?: boolean; status?: ConversationStatusEnum; metadata?: Record } = {} +): Promise { + const { session, agentId, integrationId } = ctx; + const withThread = opts.withSerializedThread ?? true; + + const conversation = await conversationRepository.create({ + identifier: `conv-e2e-${Date.now()}`, + _agentId: agentId, + participants: [ + { type: 'agent' as const, id: agentId }, + { type: 'platform_user' as const, id: 'slack:U_SEED' }, + ], + channels: [ + { + platform: 'slack', + _integrationId: integrationId, + platformThreadId: `thread-${Date.now()}`, + ...(withThread ? { serializedThread: { id: 'T_SERIALIZED', platform: 'slack' } } : {}), + }, + ], + status: opts.status ?? ConversationStatusEnum.ACTIVE, + title: 'Seeded conversation', + metadata: opts.metadata ?? {}, + _environmentId: session.environment._id, + _organizationId: session.organization._id, + lastActivityAt: new Date().toISOString(), + }); + + return conversation._id; +} + +export async function seedChannelEndpoint(ctx: AgentTestContext, platformUserId: string, subscriberId: string) { + await channelEndpointRepository.create({ + identifier: `ep-e2e-${Date.now()}`, + _environmentId: ctx.session.environment._id, + _organizationId: ctx.session.organization._id, + integrationIdentifier: ctx.integrationIdentifier, + providerId: ChatProviderIdEnum.Slack, + channel: ChannelTypeEnum.CHAT, + subscriberId, + contextKeys: [], + type: ENDPOINT_TYPES.SLACK_USER, + endpoint: { userId: platformUserId }, + }); +} diff --git a/apps/api/src/app/agents/e2e/helpers/providers/slack.ts b/apps/api/src/app/agents/e2e/helpers/providers/slack.ts new file mode 100644 index 00000000000..d2fcaf1cca2 --- /dev/null +++ b/apps/api/src/app/agents/e2e/helpers/providers/slack.ts @@ -0,0 +1,66 @@ +import { createHmac } from 'crypto'; + +export function signSlackRequest(signingSecret: string, timestamp: number, body: string) { + const sigBasestring = `v0:${timestamp}:${body}`; + const signature = 'v0=' + createHmac('sha256', signingSecret).update(sigBasestring).digest('hex'); + + return { 'x-slack-signature': signature, 'x-slack-request-timestamp': String(timestamp) }; +} + +export function buildSlackChallenge(challenge = 'test-challenge-token') { + return { type: 'url_verification', challenge, token: 'deprecated-token' }; +} + +export function buildSlackAppMention(opts: { + userId: string; + channel: string; + threadTs: string; + text?: string; + eventTs?: string; +}) { + const ts = opts.eventTs ?? `${Date.now() / 1000}`; + + return { + type: 'event_callback', + token: 'deprecated-token', + team_id: 'T_TEAM', + event: { + type: 'app_mention', + user: opts.userId, + text: opts.text ?? `<@UBOT> help me`, + ts, + channel: opts.channel, + thread_ts: opts.threadTs, + event_ts: ts, + }, + event_id: `Ev_${ts}`, + event_time: Math.floor(Date.now() / 1000), + }; +} + +export function buildSlackSubscribedMessage(opts: { + userId: string; + channel: string; + threadTs: string; + text?: string; + eventTs?: string; +}) { + const ts = opts.eventTs ?? `${Date.now() / 1000}`; + + return { + type: 'event_callback', + token: 'deprecated-token', + team_id: 'T_TEAM', + event: { + type: 'message', + user: opts.userId, + text: opts.text ?? 'follow-up message', + ts, + channel: opts.channel, + thread_ts: opts.threadTs, + event_ts: ts, + }, + event_id: `Ev_${ts}`, + event_time: Math.floor(Date.now() / 1000), + }; +}